From fba105999a7bd74b70a127bd08e2028579ee130a Mon Sep 17 00:00:00 2001 From: Noah <78898963+HaonRekcef@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:05:56 +0100 Subject: [PATCH] Improve Broadcast Deeplinks (#2597) * add app_links * Update lib/src/app_links.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Code Review --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- android/app/src/main/AndroidManifest.xml | 1 + ios/Runner/Info.plist | 2 + lib/src/app.dart | 34 +++++++--- lib/src/app_links.dart | 69 ++++++++++++-------- linux/flutter/generated_plugin_registrant.cc | 4 ++ linux/flutter/generated_plugins.cmake | 1 + pubspec.lock | 40 ++++++++++++ pubspec.yaml | 1 + 8 files changed, 115 insertions(+), 37 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fb71c2101..2cb6d3238 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -53,6 +53,7 @@ + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c83c08bf2..c49309061 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -115,6 +115,8 @@ CFBundleVersion $(FLUTTER_BUILD_NUMBER) + FlutterDeepLinkingEnabled + ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/lib/src/app.dart b/lib/src/app.dart index caf494921..52d6d55b8 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -19,7 +22,6 @@ import 'package:lichess_mobile/src/network/socket.dart'; import 'package:lichess_mobile/src/quick_actions.dart'; import 'package:lichess_mobile/src/tab_scaffold.dart'; import 'package:lichess_mobile/src/theme.dart'; -import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/utils/screen.dart'; /// Application initialization and main entry point. @@ -61,6 +63,9 @@ class Application extends ConsumerStatefulWidget { class _AppState extends ConsumerState { /// Whether the app has checked for online status for the first time. bool _firstTimeOnlineCheck = false; + final _appLinks = AppLinks(); + final _navigatorKey = GlobalKey(); + StreamSubscription? _linkSubscription; @override void initState() { @@ -103,6 +108,13 @@ class _AppState extends ConsumerState { }); super.initState(); + _initAppLinks(); + } + + @override + void dispose() { + _linkSubscription?.cancel(); + super.dispose(); } @override @@ -114,6 +126,7 @@ class _AppState extends ConsumerState { final isIOS = Theme.of(context).platform == TargetPlatform.iOS; return MaterialApp( + navigatorKey: _navigatorKey, localizationsDelegates: const [ ...AppLocalizations.localizationsDelegates, MaterialLocalizationsEo.delegate, @@ -129,16 +142,17 @@ class _AppState extends ConsumerState { context, ).copyWith(height: isShortVerticalScreen(context) ? 60 : null), ), - onGenerateRoute: (settings) => - settings.name != null ? resolveAppLinkUri(context, Uri.parse(settings.name!)) : null, - onGenerateInitialRoutes: (initialRoute) { - final homeRoute = buildScreenRoute(context, screen: const MainTabScaffold()); - return ?>[ - homeRoute, - resolveAppLinkUri(context, Uri.parse(initialRoute)), - ].nonNulls.toList(growable: false); - }, + home: const MainTabScaffold(), navigatorObservers: [rootNavPageRouteObserver], ); } + + Future _initAppLinks() async { + _linkSubscription = _appLinks.uriLinkStream.listen((uri) { + final context = _navigatorKey.currentContext; + if (context != null && context.mounted) { + handleAppLink(context, uri); + } + }); + } } diff --git a/lib/src/app_links.dart b/lib/src/app_links.dart index eb95bdd85..780ca7850 100644 --- a/lib/src/app_links.dart +++ b/lib/src/app_links.dart @@ -14,56 +14,77 @@ import 'package:lichess_mobile/src/view/study/study_screen.dart'; import 'package:lichess_mobile/src/view/tournament/tournament_screen.dart'; import 'package:lichess_mobile/src/view/user/user_or_profile_screen.dart'; import 'package:linkify/linkify.dart'; +import 'package:logging/logging.dart'; import 'package:url_launcher/url_launcher.dart'; -/// Resolves an app link [Uri] to a corresponding [Route]. -Route? resolveAppLinkUri(BuildContext context, Uri appLinkUri) { - if (appLinkUri.pathSegments.isEmpty) return null; +final _logger = Logger('AppLinks'); +/// Resolves an app link [Uri] to one or more corresponding [Route]s. +List>? resolveAppLinkUri(BuildContext context, Uri appLinkUri) { + if (appLinkUri.pathSegments.isEmpty) return null; + _logger.info('Resolving app link: $appLinkUri'); switch (appLinkUri.pathSegments[0]) { case 'study': final id = appLinkUri.pathSegments[1]; - return StudyScreen.buildRoute(context, StudyId(id)); + return [StudyScreen.buildRoute(context, StudyId(id))]; case 'broadcast': final roundId = BroadcastRoundId(appLinkUri.pathSegments[3]); if (appLinkUri.pathSegments.length > 4) { final gameId = BroadcastGameId(appLinkUri.pathSegments[4]); - return BroadcastGameScreen.buildRoute(context, roundId: roundId, gameId: gameId); + return [ + BroadcastRoundScreenLoading.buildRoute( + context, + roundId, + initialTab: BroadcastRoundTab.boards, + ), + BroadcastGameScreen.buildRoute(context, roundId: roundId, gameId: gameId), + ]; } else { final tab = BroadcastRoundTab.tabOrNullFromString(appLinkUri.fragment); - return BroadcastRoundScreenLoading.buildRoute(context, roundId, initialTab: tab); + return [BroadcastRoundScreenLoading.buildRoute(context, roundId, initialTab: tab)]; } case 'tournament': final tournamentId = TournamentId(appLinkUri.pathSegments[1]); - return TournamentScreen.buildRoute(context, tournamentId); - + return [TournamentScreen.buildRoute(context, tournamentId)]; case 'training': final id = appLinkUri.pathSegments[1]; - return PuzzleScreen.buildRoute( - context, - angle: PuzzleAngle.fromKey('mix'), - puzzleId: PuzzleId(id), - ); + return [ + PuzzleScreen.buildRoute(context, angle: PuzzleAngle.fromKey('mix'), puzzleId: PuzzleId(id)), + ]; case _: final gameId = GameId(appLinkUri.pathSegments[0]); final orientation = appLinkUri.pathSegments.getOrNull(1); final int ply = int.tryParse(appLinkUri.fragment) ?? 0; // The game id can also be a challenge. Challenge by link is not supported yet so let's ignore it. if (gameId.isValid) { - return AnalysisScreen.buildRoute( - context, - AnalysisOptions.archivedGame( - orientation: orientation == 'black' ? Side.black : Side.white, - gameId: gameId, - initialMoveCursor: ply, + return [ + AnalysisScreen.buildRoute( + context, + AnalysisOptions.archivedGame( + orientation: orientation == 'black' ? Side.black : Side.white, + gameId: gameId, + initialMoveCursor: ply, + ), ), - ); + ]; } } return null; } +/// Handles an app link [Uri] by navigating to the corresponding screen(s). +void handleAppLink(BuildContext context, Uri uri) { + final routes = resolveAppLinkUri(context, uri); + if (routes != null) { + for (final route in routes) { + Navigator.of(context).push(route); + } + } else { + launchUrl(uri); + } +} + const kLichessLinkifiers = [UrlLinkifier(), EmailLinkifier(), UserTagLinkifier()]; /// Handles link clicks in Linkify widgets throughout the app. @@ -71,13 +92,7 @@ void onLinkifyOpen(BuildContext context, LinkableElement link) { if (link is UrlElement && link.url.startsWith(RegExp('https?:\\/\\/$kLichessHost'))) { // Handle Lichess links specifically final appLinkUri = Uri.parse(link.url); - final route = resolveAppLinkUri(context, appLinkUri); - if (route != null) { - Navigator.of(context).push(route); - } else { - // If the link is not recognized, open it in a browser - launchUrl(appLinkUri); - } + handleAppLink(context, appLinkUri); } else if (link.originText.startsWith('@')) { final username = link.originText.substring(1); Navigator.of(context).push( diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 80530edab..a7934b6dc 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 178789e70..bc5ebe28f 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_system_colors file_selector_linux flutter_secure_storage_linux + gtk url_launcher_linux ) diff --git a/pubspec.lock b/pubspec.lock index b796877ad..78c19053d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" app_settings: dependency: "direct main" description: @@ -726,6 +758,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" hooks: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0fc95a7c0..e1d8d1159 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ environment: flutter: ^3.38.0 dependencies: + app_links: ^7.0.0 app_settings: ^7.0.0 async: ^2.10.0 auto_size_text: ^3.0.0