diff --git a/ui/flutter/android/app/src/main/AndroidManifest.xml b/ui/flutter/android/app/src/main/AndroidManifest.xml index 69e93226..968c7987 100644 --- a/ui/flutter/android/app/src/main/AndroidManifest.xml +++ b/ui/flutter/android/app/src/main/AndroidManifest.xml @@ -32,6 +32,12 @@ android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" /> + + diff --git a/ui/flutter/lib/app/application/app_deep_link_controller.dart b/ui/flutter/lib/app/application/app_deep_link_controller.dart index c63ca5df..548287c9 100644 --- a/ui/flutter/lib/app/application/app_deep_link_controller.dart +++ b/ui/flutter/lib/app/application/app_deep_link_controller.dart @@ -37,10 +37,15 @@ class AppDeepLinkController extends AsyncNotifier { @override Future build() async { - final runtime = ref.watch(appRuntimeControllerProvider).value; - if (runtime == null || _started || kIsWeb) { + if (_started || kIsWeb) { return AppDeepLinkState(started: _started); } + // Deep-link handling depends on the runtime being ready, but it must not + // rebuild whenever runtime settings change. A rebuild disposes the active + // platform subscriptions while `_started` remains true, leaving Android + // unable to receive later browser takeover intents (for example after the + // API server is started or stopped from Settings). + await ref.read(appRuntimeControllerProvider.future); _started = true; final appLinks = AppLinks(); _subscription = appLinks.uriLinkStream.listen((uri) { diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index 4922da34..1787289c 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -42,7 +42,7 @@ packages: source: hosted version: "1.0.3" app_links_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: app_links_platform_interface sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 6bca2e53..588d8532 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -105,6 +105,7 @@ dependency_overrides: path: permission_handler_windows dev_dependencies: + app_links_platform_interface: ^2.0.2 flutter_test: sdk: flutter diff --git a/ui/flutter/test/app_deep_link_controller_test.dart b/ui/flutter/test/app_deep_link_controller_test.dart index 388bc53f..324e026f 100644 --- a/ui/flutter/test/app_deep_link_controller_test.dart +++ b/ui/flutter/test/app_deep_link_controller_test.dart @@ -1,5 +1,13 @@ +import 'dart:async'; + +import 'package:app_links_platform_interface/app_links_platform_interface.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/app/application/app_runtime_controller.dart'; import 'package:gopeed/app/application/app_deep_link_controller.dart'; +import 'package:gopeed/core/common/api_server_state.dart'; +import 'package:gopeed/core/common/start_config.dart'; import 'package:share_handler/share_handler.dart'; void main() { @@ -27,4 +35,71 @@ void main() { expect(sharedMediaUri(media), Uri.parse('magnet:?xt=urn:btih:example')); }); + + test('keeps listening for browser takeover links after runtime settings change', () async { + final originalPlatform = AppLinksPlatform.instance; + final appLinksPlatform = _FakeAppLinksPlatform(); + AppLinksPlatform.instance = appLinksPlatform; + final container = ProviderContainer( + overrides: [appRuntimeControllerProvider.overrideWith(_FakeRuntimeController.new)], + ); + + try { + await container.read(appDeepLinkControllerProvider.future); + expect(appLinksPlatform.hasListener, isTrue); + + container.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(DownloaderConfig()); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(appLinksPlatform.hasListener, isTrue); + } finally { + container.dispose(); + await Future.delayed(Duration.zero); + AppLinksPlatform.instance = originalPlatform; + await appLinksPlatform.close(); + } + }); +} + +class _FakeAppLinksPlatform extends AppLinksPlatform { + final _links = StreamController.broadcast(); + + bool get hasListener => _links.hasListener; + + @override + Future getInitialLink() async => null; + + @override + Stream get uriLinkStream => _links.stream; + + Future close() => _links.close(); +} + +class _FakeRuntimeController extends AppRuntimeController { + @override + Future build() async { + final config = StartConfig() + ..network = 'tcp' + ..address = '127.0.0.1:9999' + ..apiEnable = true + ..apiToken = '' + ..storage = 'bolt' + ..storageDir = '' + ..refreshInterval = 0; + return AppRuntimeState( + startConfig: config, + apiServerState: const ApiServerState( + enabled: true, + mcpEnabled: false, + running: true, + network: 'tcp', + address: '127.0.0.1:9999', + runningPort: 9999, + pendingApply: false, + lastError: '', + ), + downloaderConfig: DownloaderConfig(), + ); + } }