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>
This commit is contained in:
Noah
2026-02-03 11:05:56 +01:00
committed by GitHub
parent ba62bb4303
commit fba105999a
8 changed files with 115 additions and 37 deletions
+1
View File
@@ -53,6 +53,7 @@
<data android:pathPattern="/......../black" /> <data android:pathPattern="/......../black" />
<data android:pathPattern="/......../white" /> <data android:pathPattern="/......../white" />
</intent-filter> </intent-filter>
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
</activity> </activity>
<receiver android:exported="false" <receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"/> android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"/>
+2
View File
@@ -115,6 +115,8 @@
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>
+24 -10
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/quick_actions.dart';
import 'package:lichess_mobile/src/tab_scaffold.dart'; import 'package:lichess_mobile/src/tab_scaffold.dart';
import 'package:lichess_mobile/src/theme.dart'; import 'package:lichess_mobile/src/theme.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/utils/screen.dart'; import 'package:lichess_mobile/src/utils/screen.dart';
/// Application initialization and main entry point. /// Application initialization and main entry point.
@@ -61,6 +63,9 @@ class Application extends ConsumerStatefulWidget {
class _AppState extends ConsumerState<Application> { class _AppState extends ConsumerState<Application> {
/// Whether the app has checked for online status for the first time. /// Whether the app has checked for online status for the first time.
bool _firstTimeOnlineCheck = false; bool _firstTimeOnlineCheck = false;
final _appLinks = AppLinks();
final _navigatorKey = GlobalKey<NavigatorState>();
StreamSubscription<Uri>? _linkSubscription;
@override @override
void initState() { void initState() {
@@ -103,6 +108,13 @@ class _AppState extends ConsumerState<Application> {
}); });
super.initState(); super.initState();
_initAppLinks();
}
@override
void dispose() {
_linkSubscription?.cancel();
super.dispose();
} }
@override @override
@@ -114,6 +126,7 @@ class _AppState extends ConsumerState<Application> {
final isIOS = Theme.of(context).platform == TargetPlatform.iOS; final isIOS = Theme.of(context).platform == TargetPlatform.iOS;
return MaterialApp( return MaterialApp(
navigatorKey: _navigatorKey,
localizationsDelegates: const [ localizationsDelegates: const [
...AppLocalizations.localizationsDelegates, ...AppLocalizations.localizationsDelegates,
MaterialLocalizationsEo.delegate, MaterialLocalizationsEo.delegate,
@@ -129,16 +142,17 @@ class _AppState extends ConsumerState<Application> {
context, context,
).copyWith(height: isShortVerticalScreen(context) ? 60 : null), ).copyWith(height: isShortVerticalScreen(context) ? 60 : null),
), ),
onGenerateRoute: (settings) => home: const MainTabScaffold(),
settings.name != null ? resolveAppLinkUri(context, Uri.parse(settings.name!)) : null,
onGenerateInitialRoutes: (initialRoute) {
final homeRoute = buildScreenRoute<void>(context, screen: const MainTabScaffold());
return <Route<dynamic>?>[
homeRoute,
resolveAppLinkUri(context, Uri.parse(initialRoute)),
].nonNulls.toList(growable: false);
},
navigatorObservers: [rootNavPageRouteObserver], navigatorObservers: [rootNavPageRouteObserver],
); );
} }
Future<void> _initAppLinks() async {
_linkSubscription = _appLinks.uriLinkStream.listen((uri) {
final context = _navigatorKey.currentContext;
if (context != null && context.mounted) {
handleAppLink(context, uri);
}
});
}
} }
+42 -27
View File
@@ -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/tournament/tournament_screen.dart';
import 'package:lichess_mobile/src/view/user/user_or_profile_screen.dart'; import 'package:lichess_mobile/src/view/user/user_or_profile_screen.dart';
import 'package:linkify/linkify.dart'; import 'package:linkify/linkify.dart';
import 'package:logging/logging.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
/// Resolves an app link [Uri] to a corresponding [Route]. final _logger = Logger('AppLinks');
Route<dynamic>? resolveAppLinkUri(BuildContext context, Uri appLinkUri) {
if (appLinkUri.pathSegments.isEmpty) return null;
/// Resolves an app link [Uri] to one or more corresponding [Route]s.
List<Route<dynamic>>? resolveAppLinkUri(BuildContext context, Uri appLinkUri) {
if (appLinkUri.pathSegments.isEmpty) return null;
_logger.info('Resolving app link: $appLinkUri');
switch (appLinkUri.pathSegments[0]) { switch (appLinkUri.pathSegments[0]) {
case 'study': case 'study':
final id = appLinkUri.pathSegments[1]; final id = appLinkUri.pathSegments[1];
return StudyScreen.buildRoute(context, StudyId(id)); return [StudyScreen.buildRoute(context, StudyId(id))];
case 'broadcast': case 'broadcast':
final roundId = BroadcastRoundId(appLinkUri.pathSegments[3]); final roundId = BroadcastRoundId(appLinkUri.pathSegments[3]);
if (appLinkUri.pathSegments.length > 4) { if (appLinkUri.pathSegments.length > 4) {
final gameId = BroadcastGameId(appLinkUri.pathSegments[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 { } else {
final tab = BroadcastRoundTab.tabOrNullFromString(appLinkUri.fragment); final tab = BroadcastRoundTab.tabOrNullFromString(appLinkUri.fragment);
return BroadcastRoundScreenLoading.buildRoute(context, roundId, initialTab: tab); return [BroadcastRoundScreenLoading.buildRoute(context, roundId, initialTab: tab)];
} }
case 'tournament': case 'tournament':
final tournamentId = TournamentId(appLinkUri.pathSegments[1]); final tournamentId = TournamentId(appLinkUri.pathSegments[1]);
return TournamentScreen.buildRoute(context, tournamentId); return [TournamentScreen.buildRoute(context, tournamentId)];
case 'training': case 'training':
final id = appLinkUri.pathSegments[1]; final id = appLinkUri.pathSegments[1];
return PuzzleScreen.buildRoute( return [
context, PuzzleScreen.buildRoute(context, angle: PuzzleAngle.fromKey('mix'), puzzleId: PuzzleId(id)),
angle: PuzzleAngle.fromKey('mix'), ];
puzzleId: PuzzleId(id),
);
case _: case _:
final gameId = GameId(appLinkUri.pathSegments[0]); final gameId = GameId(appLinkUri.pathSegments[0]);
final orientation = appLinkUri.pathSegments.getOrNull(1); final orientation = appLinkUri.pathSegments.getOrNull(1);
final int ply = int.tryParse(appLinkUri.fragment) ?? 0; 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. // The game id can also be a challenge. Challenge by link is not supported yet so let's ignore it.
if (gameId.isValid) { if (gameId.isValid) {
return AnalysisScreen.buildRoute( return [
context, AnalysisScreen.buildRoute(
AnalysisOptions.archivedGame( context,
orientation: orientation == 'black' ? Side.black : Side.white, AnalysisOptions.archivedGame(
gameId: gameId, orientation: orientation == 'black' ? Side.black : Side.white,
initialMoveCursor: ply, gameId: gameId,
initialMoveCursor: ply,
),
), ),
); ];
} }
} }
return null; 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()]; const kLichessLinkifiers = [UrlLinkifier(), EmailLinkifier(), UserTagLinkifier()];
/// Handles link clicks in Linkify widgets throughout the app. /// 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'))) { if (link is UrlElement && link.url.startsWith(RegExp('https?:\\/\\/$kLichessHost'))) {
// Handle Lichess links specifically // Handle Lichess links specifically
final appLinkUri = Uri.parse(link.url); final appLinkUri = Uri.parse(link.url);
final route = resolveAppLinkUri(context, appLinkUri); handleAppLink(context, appLinkUri);
if (route != null) {
Navigator.of(context).push(route);
} else {
// If the link is not recognized, open it in a browser
launchUrl(appLinkUri);
}
} else if (link.originText.startsWith('@')) { } else if (link.originText.startsWith('@')) {
final username = link.originText.substring(1); final username = link.originText.substring(1);
Navigator.of(context).push( Navigator.of(context).push(
@@ -9,6 +9,7 @@
#include <dynamic_system_colors/dynamic_color_plugin.h> #include <dynamic_system_colors/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <gtk/gtk_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
@@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); 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 = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
+1
View File
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
dynamic_system_colors dynamic_system_colors
file_selector_linux file_selector_linux
flutter_secure_storage_linux flutter_secure_storage_linux
gtk
url_launcher_linux url_launcher_linux
) )
+40
View File
@@ -33,6 +33,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" 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: app_settings:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -726,6 +758,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
gtk:
dependency: transitive
description:
name: gtk
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
url: "https://pub.dev"
source: hosted
version: "2.1.0"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -10,6 +10,7 @@ environment:
flutter: ^3.38.0 flutter: ^3.38.0
dependencies: dependencies:
app_links: ^7.0.0
app_settings: ^7.0.0 app_settings: ^7.0.0
async: ^2.10.0 async: ^2.10.0
auto_size_text: ^3.0.0 auto_size_text: ^3.0.0