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="/......../white" />
</intent-filter>
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
</activity>
<receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"/>
+2
View File
@@ -115,6 +115,8 @@
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<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_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<Application> {
/// Whether the app has checked for online status for the first time.
bool _firstTimeOnlineCheck = false;
final _appLinks = AppLinks();
final _navigatorKey = GlobalKey<NavigatorState>();
StreamSubscription<Uri>? _linkSubscription;
@override
void initState() {
@@ -103,6 +108,13 @@ class _AppState extends ConsumerState<Application> {
});
super.initState();
_initAppLinks();
}
@override
void dispose() {
_linkSubscription?.cancel();
super.dispose();
}
@override
@@ -114,6 +126,7 @@ class _AppState extends ConsumerState<Application> {
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<Application> {
context,
).copyWith(height: isShortVerticalScreen(context) ? 60 : null),
),
onGenerateRoute: (settings) =>
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);
},
home: const MainTabScaffold(),
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/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<dynamic>? 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<Route<dynamic>>? 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(
@@ -9,6 +9,7 @@
#include <dynamic_system_colors/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_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>
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);
+1
View File
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
dynamic_system_colors
file_selector_linux
flutter_secure_storage_linux
gtk
url_launcher_linux
)
+40
View File
@@ -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:
+1
View File
@@ -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