Skip to content
Merged
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
23 changes: 23 additions & 0 deletions lib/src/model/engine/nnue_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,29 @@ class NnueService {
return (bigNet: bigNetFile, smallNet: smallNetFile);
}

Future<bool> 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<bool> checkNNUEFiles() async {
final NNUEFiles files;
Expand Down
105 changes: 103 additions & 2 deletions lib/src/view/home/home_tab_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -253,7 +256,10 @@ class _HomeScreenState extends ConsumerState<HomeTabScreen> {
shouldShow: true,
child: _GreetingWidget(),
),
if (!widget.editModeEnabled) const _HomeCustomizationTip(),
if (!widget.editModeEnabled) ...[
const _HomeCustomizationTip(),
const _NNUEFilesOutdatedTip(),
],
if (status.isOnline)
_EditableWidget(
widget: HomeEditableWidget.perfCards,
Expand Down Expand Up @@ -310,7 +316,10 @@ class _HomeScreenState extends ConsumerState<HomeTabScreen> {
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,
Expand Down Expand Up @@ -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();

Expand Down
11 changes: 11 additions & 0 deletions test/model/engine/fake_nnue_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ class FakeNnueService implements NnueService {
return true;
}

@override
Future<bool> hasOutdatedNNUEFiles() async {
return false;
}

@override
Future<bool> downloadNNUEFiles({bool inBackground = true}) async {
return false;
Expand All @@ -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();
Expand All @@ -68,6 +74,11 @@ class FakeNnueServiceUnavailable implements NnueService {
return false;
}

@override
Future<bool> hasOutdatedNNUEFiles() async {
return true;
}

@override
Future<bool> downloadNNUEFiles({bool inBackground = true}) async {
return false;
Expand Down
27 changes: 27 additions & 0 deletions test/model/engine/nnue_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
89 changes: 89 additions & 0 deletions test/view/home/home_tab_screen_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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);
});
});
});
}