diff --git a/lib/src/model/engine/nnue_service.dart b/lib/src/model/engine/nnue_service.dart index 3c6c5cad8f..a40b99bc65 100644 --- a/lib/src/model/engine/nnue_service.dart +++ b/lib/src/model/engine/nnue_service.dart @@ -60,6 +60,29 @@ class NnueService { return (bigNet: bigNetFile, smallNet: smallNetFile); } + Future hasOutdatedNNUEFiles() async { + if (await checkNNUEFiles()) { + return false; + } + + final appSupportDirectory = _ref.read(preloadedDataProvider).requireValue.appSupportDirectory; + if (appSupportDirectory == null) { + return false; + } + + final NNUEFiles files = nnueFiles; + + await for (final entity in appSupportDirectory.list(followLinks: false)) { + if (entity is File && + entity.path.endsWith('.nnue') && + entity.path != files.bigNet.path && + entity.path != files.smallNet.path) { + return true; + } + } + return false; + } + /// Check the presence and integrity of the NNUE files. Future checkNNUEFiles() async { final NNUEFiles files; diff --git a/lib/src/view/home/home_tab_screen.dart b/lib/src/view/home/home_tab_screen.dart index 35579b5be0..e6b65d85f5 100644 --- a/lib/src/view/home/home_tab_screen.dart +++ b/lib/src/view/home/home_tab_screen.dart @@ -17,6 +17,8 @@ import 'package:lichess_mobile/src/model/challenge/challenges.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/correspondence/correspondence_game_storage.dart'; import 'package:lichess_mobile/src/model/correspondence/offline_correspondence_game.dart'; +import 'package:lichess_mobile/src/model/engine/evaluation_preferences.dart'; +import 'package:lichess_mobile/src/model/engine/nnue_service.dart'; import 'package:lichess_mobile/src/model/game/game_history.dart'; import 'package:lichess_mobile/src/model/message/message_repository.dart'; import 'package:lichess_mobile/src/model/tournament/tournament.dart'; @@ -45,6 +47,7 @@ import 'package:lichess_mobile/src/view/play/ongoing_games_screen.dart'; import 'package:lichess_mobile/src/view/play/play_bottom_sheet.dart'; import 'package:lichess_mobile/src/view/play/play_menu.dart'; import 'package:lichess_mobile/src/view/play/quick_game_matrix.dart'; +import 'package:lichess_mobile/src/view/settings/engine_settings_screen.dart'; import 'package:lichess_mobile/src/view/tournament/tournament_list_screen.dart'; import 'package:lichess_mobile/src/view/user/challenge_requests_screen.dart'; import 'package:lichess_mobile/src/view/user/player_screen.dart'; @@ -253,7 +256,10 @@ class _HomeScreenState extends ConsumerState { shouldShow: true, child: _GreetingWidget(), ), - if (!widget.editModeEnabled) const _HomeCustomizationTip(), + if (!widget.editModeEnabled) ...[ + const _HomeCustomizationTip(), + const _NNUEFilesOutdatedTip(), + ], if (status.isOnline) _EditableWidget( widget: HomeEditableWidget.perfCards, @@ -310,7 +316,10 @@ class _HomeScreenState extends ConsumerState { shouldShow: true, child: _GreetingWidget(), ), - if (!widget.editModeEnabled) const _HomeCustomizationTip(), + if (!widget.editModeEnabled) ...[ + const _HomeCustomizationTip(), + const _NNUEFilesOutdatedTip(), + ], _EditableWidget( widget: HomeEditableWidget.perfCards, shouldShow: authUser != null && status.isOnline, @@ -1000,6 +1009,98 @@ class _WelcomeMessageCardState extends State<_WelcomeMessageCard> { } } +class _NNUEFilesOutdatedTip extends ConsumerStatefulWidget { + const _NNUEFilesOutdatedTip(); + + @override + ConsumerState<_NNUEFilesOutdatedTip> createState() => _NNUEFilesOutdatedTipState(); +} + +class _NNUEFilesOutdatedTipState extends ConsumerState<_NNUEFilesOutdatedTip> { + bool _openedSettings = false; + + @override + Widget build(BuildContext context) { + final chessEnginePref = ref.watch(engineEvaluationPreferencesProvider).enginePref; + if (chessEnginePref != ChessEnginePref.sfLatest) { + return const SizedBox.shrink(); + } + + final nnueService = ref.watch(nnueServiceProvider); + if (nnueService.isDownloadingNNUEFiles) { + return const SizedBox.shrink(); + } + + return FocusDetector( + // If we come back from the settings, trigger rebuild to hide the widget if the user has updated the NNUE files + onFocusRegained: () { + if (_openedSettings) { + setState(() {}); + } + }, + child: FutureBuilder( + future: nnueService.hasOutdatedNNUEFiles(), + builder: (context, snapshot) { + final hasOutdatedNNUEFiles = snapshot.data ?? false; + if (!hasOutdatedNNUEFiles) { + return const SizedBox.shrink(); + } + + return Padding( + padding: Styles.bodyPadding, + child: Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Icon( + Icons.warning, + size: 25.0, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8.0), + const Flexible( + child: Text( + // TODO l10n + 'New Stockfish version available! Go to the settings to download the updated NNUE files.', + ), + ), + ], + ), + ), + Row( + children: [ + TextButton( + onPressed: () { + setState(() { + _openedSettings = true; + }); + Navigator.of( + context, + rootNavigator: true, + ).push(EngineSettingsScreen.buildRoute(context)); + }, + // TODO l10n + child: const Text('Open settings'), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + class _HomeCustomizationTip extends StatefulWidget { const _HomeCustomizationTip(); diff --git a/test/model/engine/fake_nnue_service.dart b/test/model/engine/fake_nnue_service.dart index 9aeed90b19..5d043198c1 100644 --- a/test/model/engine/fake_nnue_service.dart +++ b/test/model/engine/fake_nnue_service.dart @@ -32,6 +32,11 @@ class FakeNnueService implements NnueService { return true; } + @override + Future hasOutdatedNNUEFiles() async { + return false; + } + @override Future downloadNNUEFiles({bool inBackground = true}) async { return false; @@ -46,6 +51,7 @@ class FakeNnueService implements NnueService { /// A fake [NnueService] that simulates missing/unavailable NNUE files. /// /// - Always returns false for [checkNNUEFiles] +/// - Always returns true for [hasOutdatedNNUEFiles] /// - All other behaviour is identical to [FakeNnueService] class FakeNnueServiceUnavailable implements NnueService { FakeNnueServiceUnavailable(); @@ -68,6 +74,11 @@ class FakeNnueServiceUnavailable implements NnueService { return false; } + @override + Future hasOutdatedNNUEFiles() async { + return true; + } + @override Future downloadNNUEFiles({bool inBackground = true}) async { return false; diff --git a/test/model/engine/nnue_service_test.dart b/test/model/engine/nnue_service_test.dart index 92419f891d..e96712d71c 100644 --- a/test/model/engine/nnue_service_test.dart +++ b/test/model/engine/nnue_service_test.dart @@ -159,6 +159,33 @@ void main() { }); }); + group('hasOutdatedNNUEFiles', () { + test('returns false when appSupportDirectory is null', () async { + final container = await makeNnueTestContainer(appSupportDirectory: null); + addTearDown(container.dispose); + + final service = container.read(nnueServiceProvider); + final result = await service.hasOutdatedNNUEFiles(); + + expect(result, isFalse); + }); + + test('returns true if we have outdated nnue files', () async { + final tempDir = await Directory.systemTemp.createTemp('nnue_test_'); + addTearDown(() => tempDir.delete(recursive: true)); + + File('${tempDir.path}/someOldFile.nnue').create(); + + final container = await makeNnueTestContainer(appSupportDirectory: tempDir); + addTearDown(container.dispose); + + final service = container.read(nnueServiceProvider); + final result = await service.hasOutdatedNNUEFiles(); + + expect(result, isTrue); + }); + }); + group('deleteNNUEFiles', () { test('throws exception when appSupportDirectory is null', () async { final container = await makeNnueTestContainer(appSupportDirectory: null); diff --git a/test/view/home/home_tab_screen_test.dart b/test/view/home/home_tab_screen_test.dart index cb96232f52..15f763f595 100644 --- a/test/view/home/home_tab_screen_test.dart +++ b/test/view/home/home_tab_screen_test.dart @@ -1,9 +1,14 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/testing.dart'; import 'package:lichess_mobile/src/app.dart'; +import 'package:lichess_mobile/src/model/engine/evaluation_preferences.dart'; +import 'package:lichess_mobile/src/model/engine/nnue_service.dart'; import 'package:lichess_mobile/src/model/game/game_storage.dart'; +import 'package:lichess_mobile/src/model/settings/preferences_storage.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/styles/lichess_icons.dart'; import 'package:lichess_mobile/src/view/game/game_list_tile.dart'; @@ -18,6 +23,7 @@ import '../../example_data.dart'; import '../../mock_server_responses.dart'; import '../../model/auth/fake_auth_storage.dart'; import '../../model/challenge/challenge_repository_test.dart'; +import '../../model/engine/fake_nnue_service.dart'; import '../../network/fake_http_client_factory.dart'; import '../../test_helpers.dart'; import '../../test_provider_scope.dart'; @@ -464,5 +470,88 @@ void main() { expect(find.text(customizeTip), findsNothing); }); }); + + group('NNUE files missing tip', () { + const nnueFilesMissingTip = + 'New Stockfish version available! Go to the settings to download the updated NNUE files.'; + testWidgets('Shown if engine pref is latest sf and NNUE files are missing', (tester) async { + final app = await makeTestProviderScope( + tester, + overrides: { + nnueServiceProvider: nnueServiceProvider.overrideWithValue( + FakeNnueServiceUnavailable(), + ), + }, + authUser: fakeAuthUser, + defaultPreferences: { + PrefCategory.engineEvaluation.storageKey: jsonEncode( + EngineEvaluationPrefState.defaults + .copyWith(enginePref: ChessEnginePref.sfLatest) + .toJson(), + ), + }, + child: const Application(), + ); + + await tester.pumpWidget(app); + + // Wait for hasOutdatedNNUEFiles() future to complete + await tester.pumpAndSettle(); + + expect(find.text(nnueFilesMissingTip), findsOneWidget); + }); + + testWidgets('Not shown if nnue files are available', (tester) async { + final app = await makeTestProviderScope( + tester, + overrides: { + nnueServiceProvider: nnueServiceProvider.overrideWithValue(FakeNnueService()), + }, + authUser: fakeAuthUser, + defaultPreferences: { + PrefCategory.engineEvaluation.storageKey: jsonEncode( + EngineEvaluationPrefState.defaults + .copyWith(enginePref: ChessEnginePref.sfLatest) + .toJson(), + ), + }, + child: const Application(), + ); + + await tester.pumpWidget(app); + + // Wait for hasOutdatedNNUEFiles() future to complete + await tester.pumpAndSettle(); + + expect(find.text(nnueFilesMissingTip), findsNothing); + }); + + testWidgets('Not shown if engine pref is sf16', (tester) async { + final app = await makeTestProviderScope( + tester, + overrides: { + nnueServiceProvider: nnueServiceProvider.overrideWithValue( + FakeNnueServiceUnavailable(), + ), + }, + authUser: fakeAuthUser, + defaultPreferences: { + PrefCategory.engineEvaluation.storageKey: jsonEncode( + EngineEvaluationPrefState.defaults + .copyWith(enginePref: ChessEnginePref.sf16) + .toJson(), + ), + }, + child: const Application(), + ); + + await tester.pumpWidget(app); + + // Wait for hasOutdatedNNUEFiles() future to complete + await tester.pumpAndSettle(); + + expect(find.text(nnueFilesMissingTip), findsNothing); + }); + }); }); }