Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:theme="@style/SplashTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
Expand All @@ -33,6 +33,26 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="application/x-chess-pgn" />
<data android:mimeType="application/vnd.chess-pgn" />
<data android:mimeType="text/x-chess-pgn" />
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data android:scheme="content" />

<data android:pathSuffix=".pgn" />
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
Expand Down
44 changes: 44 additions & 0 deletions lib/src/app.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';

import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
Expand All @@ -23,6 +24,8 @@ 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/screen.dart';
import 'package:lichess_mobile/src/view/more/import_pgn_screen.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';

/// Application initialization and main entry point.
class AppInitializationScreen extends ConsumerWidget {
Expand Down Expand Up @@ -65,7 +68,9 @@ class _AppState extends ConsumerState<Application> {
bool _firstTimeOnlineCheck = false;
final _appLinks = AppLinks();
final _navigatorKey = GlobalKey<NavigatorState>();

StreamSubscription<Uri>? _linkSubscription;
StreamSubscription<List<SharedMediaFile>>? _intentSub;

@override
void initState() {
Expand Down Expand Up @@ -109,11 +114,13 @@ class _AppState extends ConsumerState<Application> {

super.initState();
_initAppLinks();
_initSharingIntent();
}

@override
void dispose() {
_linkSubscription?.cancel();
_intentSub?.cancel();
super.dispose();
}

Expand Down Expand Up @@ -149,10 +156,47 @@ class _AppState extends ConsumerState<Application> {

Future<void> _initAppLinks() async {
_linkSubscription = _appLinks.uriLinkStream.listen((uri) {
// File links are handled by the sharing intent logic, so we can ignore them here.
if (uri.scheme == 'file' || uri.scheme == 'content') {
return;
}
final context = _navigatorKey.currentContext;
if (context != null && context.mounted) {
handleAppLink(context, uri);
}
});
}

void _initSharingIntent() {
// Warm start
_intentSub = ReceiveSharingIntent.instance.getMediaStream().listen((
List<SharedMediaFile> value,
) {
_processSharedFiles(value);
});

// Cold start
ReceiveSharingIntent.instance.getInitialMedia().then((List<SharedMediaFile> value) {
_processSharedFiles(value);
ReceiveSharingIntent.instance.reset();
});
}

Future<void> _processSharedFiles(List<SharedMediaFile> files) async {
if (files.isEmpty) return;
final filePath = files.first.path;
try {
final context = _navigatorKey.currentContext;
if (context == null || !context.mounted) return;

Comment on lines +185 to +191
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race condition in the cold start handling. The getInitialMedia() call is made in initState(), but at that point the navigator context might not be ready yet, causing the file processing to silently fail when _navigatorKey.currentContext is null. Consider adding a retry mechanism or deferring the cold start processing until after the first frame is built using WidgetsBinding.instance.addPostFrameCallback(). This would ensure the navigator is ready to handle the navigation.

Suggested change
Future<void> _processSharedFiles(List<SharedMediaFile> files) async {
if (files.isEmpty) return;
final filePath = files.first.path;
try {
final context = _navigatorKey.currentContext;
if (context == null || !context.mounted) return;
Future<void> _processSharedFiles(
List<SharedMediaFile> files, {
int attemptsLeft = 3,
}) async {
if (files.isEmpty) return;
final context = _navigatorKey.currentContext;
if (context == null || !context.mounted) {
if (attemptsLeft <= 0) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_processSharedFiles(
files,
attemptsLeft: attemptsLeft - 1,
);
});
return;
}
final filePath = files.first.path;
try {

Copilot uses AI. Check for mistakes.
final file = File(filePath);
final pgnText = await file.readAsString();

if (context.mounted) {
ImportPgnScreen.handlePgnText(context, pgnText);
}
} catch (e) {
debugPrint('Failed to process incoming file: $e');
}
}
}
78 changes: 39 additions & 39 deletions lib/src/view/more/import_pgn_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,43 @@ class ImportPgnScreen extends StatelessWidget {
return buildScreenRoute(context, screen: const ImportPgnScreen());
}

static void handlePgnText(BuildContext context, String text) {
try {
final games = PgnGame.parseMultiGamePgn(text);

if (games.isEmpty) {
showSnackBar(context, context.l10n.invalidPgn, type: .error);
return;
}

if (games.length == 1) {
final game = games.first;
final rule = Rule.fromPgn(game.headers['Variant']);

Navigator.of(context, rootNavigator: true).push(
AnalysisScreen.buildRoute(
context,
AnalysisOptions.pgn(
id: const StringId('pgn_import_single_game'),
orientation: .white,
pgn: text,
isComputerAnalysisAllowed: true,
initialMoveCursor: game.moves.mainline().isEmpty ? 0 : 1,
variant: rule != null ? Variant.fromRule(rule) : .standard,
),
),
);
} else {
Navigator.of(
context,
rootNavigator: true,
).push(PgnGamesListScreen.buildRoute(context, games.lock));
}
} catch (_) {
showSnackBar(context, context.l10n.invalidPgn, type: .error);
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
Expand Down Expand Up @@ -73,43 +110,6 @@ class _BodyState extends State<_Body> {
);
}

void _handlePgnText(String text) {
try {
final games = PgnGame.parseMultiGamePgn(text);

if (games.isEmpty) {
showSnackBar(context, context.l10n.invalidPgn, type: SnackBarType.error);
return;
}

if (games.length == 1) {
final game = games.first;
final rule = Rule.fromPgn(game.headers['Variant']);

Navigator.of(context, rootNavigator: true).push(
AnalysisScreen.buildRoute(
context,
AnalysisOptions.pgn(
id: const StringId('pgn_import_single_game'),
orientation: Side.white,
pgn: text,
isComputerAnalysisAllowed: true,
initialMoveCursor: game.moves.mainline().isEmpty ? 0 : 1,
variant: rule != null ? Variant.fromRule(rule) : Variant.standard,
),
),
);
} else {
Navigator.of(
context,
rootNavigator: true,
).push(PgnGamesListScreen.buildRoute(context, games.lock));
}
} catch (_) {
showSnackBar(context, context.l10n.invalidPgn, type: SnackBarType.error);
}
}

Future<void> _getClipboardData() async {
final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text == null) return;
Expand All @@ -118,7 +118,7 @@ class _BodyState extends State<_Body> {
final text = data!.text!.trim();
if (text.isEmpty) return;

_handlePgnText(text);
ImportPgnScreen.handlePgnText(context, text);
}

Future<void> _pickPgnFile() async {
Expand All @@ -132,7 +132,7 @@ class _BodyState extends State<_Body> {
if (result != null && result.files.single.bytes != null) {
final content = utf8.decode(result.files.single.bytes!);
if (mounted) {
_handlePgnText(content);
ImportPgnScreen.handlePgnText(context, content);
}
}
} catch (e) {
Expand Down
9 changes: 9 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
receive_sharing_intent:
dependency: "direct main"
description:
path: "."
ref: "2cea396843cd3ab1b5ec4334be4233864637874e"
resolved-ref: "2cea396843cd3ab1b5ec4334be4233864637874e"
url: "https://github.com/KasemJaffer/receive_sharing_intent"
source: git
version: "1.8.1"
result_extensions:
dependency: "direct main"
description:
Expand Down
4 changes: 4 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ dependencies:
popover: ^0.4.0
pub_semver: ^2.1.4
quick_actions: ^1.1.0
receive_sharing_intent:
git:
url: https://github.com/KasemJaffer/receive_sharing_intent
ref: 2cea396843cd3ab1b5ec4334be4233864637874e
result_extensions: ^0.2.0
share_plus: ^12.0.0
shared_preferences: ^2.1.0
Expand Down
Loading