Skip to content

commit d973766

abduznik edited this page May 23, 2026 · 1 revision

feat: implement high-performance ROM sync with incremental scanning and Hive persistence

Commit: d973766f288e1eac4e11c3bcfd19202fbe30e57c

Author: abduznik

Date: 2026-04-18

Why: Adds a new feature or capability to the application.

Files Changed

lib/core/romm/rom_scanner_service.dart             | 142 ++++++++++++++++++++
 lib/core/romm/romm_service.dart                    |  25 ++++
 lib/core/storage/rom_mapping_service.dart          |  62 +++++++++
 lib/main.dart                                      |   4 +-
 lib/providers/downloaded_games_cache_provider.dart | 143 ++++++---------------
 lib/providers/romm_provider.dart                   |  18 +++
 lib/ui/screens/library_screen.dart                 |   4 +-
 pubspec.lock                                       |  16 +++
 pubspec.yaml                                       |   2 +
 test/unit/firmware_service_test.mocks.dart         |  24 ++++
 test/unit/paginated_games_provider_test.mocks.dart |  24 ++++
 test/unit/save_sync_service_test.mocks.dart        |  24 ++++
 test/widgets/library_screen_test.dart              |  16 ++-
 test/widgets/library_screen_test.mocks.dart        | 109 ++++++++++++++++
 test/widgets/settings_screen_test.mocks.dart       |  24 ++++
 15 files changed, 529 insertions(+), 108 deletions(-)
  • lib/core/romm/rom_scanner_service.dart
  • lib/core/romm/romm_service.dart
  • lib/core/storage/rom_mapping_service.dart
  • lib/main.dart
  • lib/providers/downloaded_games_cache_provider.dart
  • lib/providers/romm_provider.dart
  • lib/ui/screens/library_screen.dart
  • pubspec.lock
  • pubspec.yaml
  • test/unit/firmware_service_test.mocks.dart
  • test/unit/paginated_games_provider_test.mocks.dart
  • test/unit/save_sync_service_test.mocks.dart
  • test/widgets/library_screen_test.dart
  • test/widgets/library_screen_test.mocks.dart
  • test/widgets/settings_screen_test.mocks.dart

Diff

diff --git a/lib/core/romm/rom_scanner_service.dart b/lib/core/romm/rom_scanner_service.dart
new file mode 100644
index 0000000..cb84801
--- /dev/null
+++ b/lib/core/romm/rom_scanner_service.dart
@@ -0,0 +1,142 @@
+import 'dart:io';
+import 'dart:isolate';
+import 'package:flutter/foundation.dart';
+import 'package:path/path.dart' as p;
+import 'romm_service.dart';
+import '../storage/rom_mapping_service.dart';
+import 'romm_models.dart';
+import 'package:crypto/crypto.dart';
+
+class RomSyncResult {
+  final String path;
+  final String? romId;
+  final Game? game;
+
+  RomSyncResult(this.path, this.romId, {this.game});
+}
+
+class RomScannerService {
+  final RommService _rommService;
+  final RomMappingService _mappingService;
+
+  RomScannerService(this._rommService, this._mappingService);
+
+  /// Performs an incremental sync of the ROM directory.
+  Stream<RomSyncResult> sync(String romsRoot) async* {
+    final storedMTimes = _mappingService.getMTimes();
+    final mappings = _mappingService.getMappings();
+    
+    final rootDir = Directory(romsRoot);
+    if (!await rootDir.exists()) return;
+
+    // Phase 1: Check root and platform directories for changes
+    final rootStat = await rootDir.stat();
+    bool rootChanged = storedMTimes[romsRoot] != rootStat.modified.millisecondsSinceEpoch;
+
+    final List<Directory> dirtyDirs = [];
+    if (rootChanged) {
+      dirtyDirs.add(rootDir);
+    }
+
+    // Even if root mtime hasn't changed, we should check platform subdirs 
+    // because some filesystems don't propagate mtime changes upwards.
+    await for (final entity in rootDir.list()) {
+      if (entity is Directory) {
+        final platformMTime = storedMTimes[entity.path];
+        final stat = await entity.stat();
+        if (platformMTime != stat.modified.millisecondsSinceEpoch) {
+          dirtyDirs.add(entity);
+        }
+      }
+    }
+
+    if (dirtyDirs.isEmpty) {
+      debugPrint('[RomScanner] No directories changed. Skipping scan.');
+      return;
+    }
+
+    debugPrint('[RomScanner] Scanning ${dirtyDirs.length} dirty directories...');
+
+    // Phase 2: Identify new/removed files in dirty directories using an Isolate
+    final List<String> dirPaths = dirtyDirs.map((d) => d.path).toList();
+    final List<String> allFiles = await Isolate.run(() => _scanDirectories(dirPaths));
+
+    // Phase 3: Update mappings and match new files
+    final Map<String, int> newMTimes = Map.from(storedMTimes);
+    for (final dir in dirtyDirs) {
+      final stat = await dir.stat();
+      newMTimes[dir.path] = stat.modified.millisecondsSinceEpoch;
+    }
+    await _mappingService.saveMTimes(newMTimes);
+
+    final Set<String> existingFiles = mappings.keys.toSet();
+    final Set<String> currentFilesSet = allFiles.toSet();
+
+    // Identify truly new files
+    final List<String> newFiles = allFiles.where((f) => !existingFiles.contains(f)).toList();
+    
+    // Clean up removed files from mappings
+    final List<String> removedFiles = existingFiles.where((f) => !currentFilesSet.contains(f)).toList();
+    if (removedFiles.isNotEmpty) {
+      final updatedMappings = Map<String, String>.from(mappings);
+      for (final f in removedFiles) {
+        updatedMappings.remove(f);
+      }
+      await _mappingService.saveMappings(updatedMappings);
+    }
+
+    if (newFiles.isEmpty) {
+      debugPrint('[RomScanner] No new files found in dirty directories.');
+      return;
+    }
+
+    debugPrint('[RomScanner] Matching ${newFiles.length} new files...');
+
+    // Phase 4: Match new files via RomM API
+    for (final filePath in newFiles) {
+      final fileName = p.basename(filePath);
+      
+      // Try filename match first (fast)
+      final searchResult = await _rommService.searchRoms(search: fileName);
+      if (searchResult.isNotEmpty) {
+        final game = searchResult.first;
+        await _mappingService.updateMapping(filePath, game.id);
+        yield RomSyncResult(filePath, game.id, game: game);
+        continue;
+      }
+
+      // Optional: SHA1 match (slower, could be triggered by user or done here)
+      // For now, let's keep it simple and just yield the path if not found
+      yield RomSyncResult(filePath, null);
+    }
+  }
+
+  /// Calculates SHA1 for a file. Can be used for deep matching.
+  static Future<String> calculateSha1(String path) async {
+    final file = File(path);
+    if (!await file.exists()) return '';
+    final bytes = await file.readAsBytes();
+    return sha1.convert(bytes).toString();
+  }
+}
+
+/// Helper function to be run in an Isolate
+List<String> _scanDirectories(List<String> paths) {
+  final List<String> files = [];
+  for (final path in paths) {
+    final dir = Directory(path);
+    if (dir.existsSync()) {
+      // Shallow scan: only files in the directory
+      // (Assuming ROMs/Platform/File structure)
+      for (final entity in dir.listSync()) {
+        if (entity is File) {
+          files.add(entity.path);
+        } else if (entity is Directory) {
+           // Handle folders that are treated as games (e.g. Windows)
+           files.add(entity.path);
+        }
+      }
+    }
+  }
+  return files;
+}
diff --git a/lib/core/romm/romm_service.dart b/lib/core/romm/romm_service.dart
index 0895025..34baf4b 100644
--- a/lib/core/romm/romm_service.dart
+++ b/lib/core/romm/romm_service.dart
@@ -201,6 +201,31 @@ class RommService {
     } catch (_) { return []; }
   }
 
+  Future<List<Game>> searchRoms({String? sha1, String? md5, String? search}) async {
+    final params = <String, dynamic>{
+      'limit': 10,
+      'offset': 0,
+      'with_char_index': false,
+      'with_filter_values': false,
+    };
+    if (sha1 != null) params['sha1'] = sha1;
+    if (md5 != null) params['md5'] = md5;
+    if (search != null) params['search_term'] = search;
+
+    try {
+      final response = await _dio.get('/api/roms', queryParameters: params, options: _authOptions);
+      if (response.statusCode == 200) {
+        final Map<String, dynamic> data = response.data is Map ? response.data : {'items': response.data};
+        final List<dynamic> items = data['items'] ?? [];
+        return items.map((e) => Game.fromJson(e as Map<String, dynamic>)).toList();
+      }
+      return [];
+    } catch (e) {
+      debugPrint('[RomM] searchRoms error: $e');
+      return [];
+    }
+  }
+
   Future<({List<Game> games, int total})> getGamesPage({int offset = 0, int limit = 50, String? platformId, String? search, List<String> genres = const [], List<String> regions = const [], List<String> languages = const [], List<String> collections = const [], List<String> statuses = const [], bool? lastPlayed, bool withCharIndex = false, bool withFilterValues = false}) async {
     final params = <String, dynamic>{'limit': limit, 'offset': offset, 'order_by': 'name', 'order_dir': 'asc', 'with_char_index': withCharIndex, 'with_filter_values': withFilterValues};
     if (lastPlayed != null) params['last_played'] = lastPlayed;
diff --git a/lib/core/storage/rom_mapping_service.dart b/lib/core/storage/rom_mapping_service.dart
new file mode 100644
index 0000000..6c9dcfc
--- /dev/null
+++ b/lib/core/storage/rom_mapping_service.dart
@@ -0,0 +1,62 @@
+import 'package:hive_flutter/hive_flutter.dart';
+
+class RomMappingService {
+  static const String _boxName = 'rom_mappings_v2';
+  late Box _box;
+
+  static const String _keyMappings = 'path_to_id';
+  static const String _keyMTimes = 'dir_mtimes';
+  static const String _keyLastSync = 'last_sync_time';
+
+  Future<void> init() async {
+    _box = await Hive.openBox(_boxName);
+  }
+
+  /// Map of FilePath -> RomID
+  Map<String, String> getMappings() {
+    final data = _box.get(_keyMappings);
+    if (data is Map) {
+      return Map<String, String>.from(data);
+    }
+    return {};
+  }
+
+  Future<void> saveMappings(Map<String, String> mappings) async {
+    await _box.put(_keyMappings, mappings);
+  }
+
+  Future<void> updateMapping(String path, String romId) async {
+    final mappings = getMappings();
+    mappings[path] = romId;
+    await saveMappings(mappings);
+  }
+
+  /// Map of DirectoryPath -> LastModifiedTimestamp (ms)
+  Map<String, int> getMTimes() {
+    final data = _box.get(_keyMTimes);
+    if (data is Map) {
+      return Map<String, int>.from(data);
+    }
+    return {};
+  }
+
+  Future<void> saveMTimes(Map<String, int> mtimes) async {
+    await _box.put(_keyMTimes, mtimes);
+  }
+
+  int? getLastSyncTime() {
+    return _box.get(_keyLastSync) as int?;
+  }
+
+  Future<void> setLastSyncTime(int timestamp) async {
+    await _box.put(_keyLastSync, timestamp);
+  }
+
+  String? getRomIdForPath(String path) {
+    return getMappings()[path];
+  }
+
+  Future<void> clear() async {
+    await _box.clear();
+  }
+}
diff --git a/lib/main.dart b/lib/main.dart
index 4f4a952..d1910f7 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,9 +1,11 @@
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:hive_flutter/hive_flutter.dart';
 import 'app.dart';
 
-void main() {
+void main() async {
   WidgetsFlutterBinding.ensureInitialized();
+  await Hive.initFlutter();
   runApp(
     const ProviderScope(
       child: FreegosyApp(),
diff --git a/lib/providers/downloaded_games_cache_provider.dart b/lib/providers/downloaded_games_cache_provider.dart
index de824bd..ff64974 100644
--- a/lib/providers/downloaded_games_cache_provider.dart
+++ b/lib/providers/downloaded_games_cache_provider.dart
@@ -1,5 +1,4 @@
 import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../core/romm/romm_models.dart';
 import 'download_provider.dart';
 import 'romm_provider.dart';
 import 'dart:async';
@@ -7,18 +6,18 @@ import 'package:flutter/foundation.dart';
 
 class DownloadedGamesCache extends StateNotifier<Map<String, bool>> {
   final Ref _ref;
-  bool _isDeepScanning = false;
-  Timer? _scanTimer;
+  bool _isSyncing = false;
+  Timer? _syncTimer;
   Timer? _periodicTimer;
   
   DownloadedGamesCache(this._ref) : super({}) {
     _init();
   }
 
-  bool get isDeepScanning => _isDeepScanning;
+  bool get isSyncing => _isSyncing;
 
   void _init() {
-    // Initial scan of whatever is already in memory
+    // Initial load from local mappings
     refresh();
     
     // Listen to download progress
@@ -38,136 +37,72 @@ class DownloadedGamesCache extends StateNotifier<Map<String, bool>> {
       }
     });
 
-    // Start deep scan in background after a short delay
-    _scanTimer = Timer(const Duration(seconds: 5), () => startDeepScan());
+    // Start incremental sync in background after a short delay
+    _syncTimer = Timer(const Duration(seconds: 3), () => startIncrementalSync());
 
-    // Periodical refresh every 2 minutes to catch external filesystem changes
-    _periodicTimer = Timer.periodic(const Duration(minutes: 2), (_) => refresh());
+    // Periodical refresh every 5 minutes
+    _periodicTimer = Timer.periodic(const Duration(minutes: 5), (_) => startIncrementalSync());
   }
 
   @override
   void dispose() {
-    _scanTimer?.cancel();
+    _syncTimer?.cancel();
     _periodicTimer?.cancel();
-    _isDeepScanning = false;
+    _isSyncing = false;
     super.dispose();
   }
 
-  /// Quickly refreshes the download status for games currently in the metadata cache.
+  /// Quickly populates the cache from locally stored mappings.
   Future<void> refresh() async {
-    final directoryService = _ref.read(directoryServiceProvider).asData?.value;
-    if (directoryService == null) return;
+    final mappingService = _ref.read(romMappingServiceProvider).asData?.value;
+    if (mappingService == null) return;
 
-    final downloadedByPlatform = await directoryService.getAllDownloadedFileNamesByPlatform();
-    final metadataCache = _ref.read(metadataCacheServiceProvider).asData?.value;
-    
-    final List<Game> gamesToScan = metadataCache?.cachedGames ?? [];
-    if (gamesToScan.isEmpty) return;
-
-    final Map<String, bool> newState = Map<String, bool>.from(state);
-    for (final game in gamesToScan) {
-      final platformSlug = game.platformSlug ?? '';
-      final downloadedNames = downloadedByPlatform[platformSlug] ?? {};
-      
-      final fileName = (game.fsName ?? game.fileName ?? game.name)
-          .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
-          .toLowerCase();
-      
-      newState[game.id] = downloadedNames.contains(fileName);
+    final mappings = mappingService.getMappings();
+    final Map<String, bool> newState = {};
+    for (final romId in mappings.values) {
+      newState[romId] = true;
     }
     state = newState;
   }
 
-  /// Aggressively fetches all games from RomM in the background to find EVERYTHING downloaded.
-  Future<void> startDeepScan() async {
-    if (_isDeepScanning) return;
+  /// Runs the high-performance incremental sync.
+  Future<void> startIncrementalSync() async {
+    if (_isSyncing) return;
     
-    final service = _ref.read(rommServiceProvider);
+    final scanner = _ref.read(romScannerServiceProvider);
     final dirService = _ref.read(directoryServiceProvider).asData?.value;
     final metadataCache = _ref.read(metadataCacheServiceProvider).asData?.value;
     
-    if (service == null || dirService == null || metadataCache == null) {
+    if (scanner == null || dirService == null || metadataCache == null) {
       // Retry in a bit if services aren't ready
-      Future.delayed(const Duration(seconds: 10), () => startDeepScan());
+      Future.delayed(const Duration(seconds: 10), () => startIncrementalSync());
       return;
     }
 
-    _isDeepScanning = true;
-    debugPrint('[DownloadedGamesCache] Starting background deep scan...');
+    _isSyncing = true;
+    debugPrint('[DownloadedGamesCache] Starting incremental sync...');
 
     try {
-      final platforms = await service.getPlatforms();
-      final downloadedByPlatform = await dirService.getAllDownloadedFileNamesByPlatform();
+      final romsRoot = await dirService.getRomsDirectory();
       
-      if (downloadedByPlatform.isEmpty) {
-        _isDeepScanning = false;
-        return;
-      }
-
-      final Map<String, bool> newState = Map<String, bool>.from(state);
-      
-      for (final platform in platforms) {
-        final slug = platform.slug;
-        if (!downloadedByPlatform.containsKey(slug) && !downloadedByPlatform.containsKey(platform.name.toLowerCase())) {
-          continue;
-        }
-
-        final localFiles = downloadedByPlatform[slug] ?? downloadedByPlatform[platform.name.toLowerCase()] ?? {};
-        if (localFiles.isEmpty) continue;
-
-        debugPrint('[DownloadedGamesCache] Scanning platform ${platform.name} for ${localFiles.length} local files...');
-
-        int offset = 0;
-        const int limit = 100;
-        
-        while (true) {
-          final result = await service.getGamesPage(
-            platformId: platform.id.toString(),
-            offset: offset,
-            limit: limit,
-          );
-
-          if (result.games.isEmpty) break;
-
-          // Save to metadata cache so they are available offline
-          await metadataCache.saveGames(result.games);
-
-          for (final game in result.games) {
-            final fileName = (game.fsName ?? game.fileName ?? game.name)
-                .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
-                .toLowerCase();
-            
-            if (localFiles.contains(fileName)) {
-              newState[game.id] = true;
-            } else {
-              // Also check without extension if local file might have different extension
-              final stem = fileName.contains('.') ? fileName.substring(0, fileName.lastIndexOf('.')) : fileName;
-              bool found = false;
-              for (final localFile in localFiles) {
-                if (localFile.startsWith(stem)) {
-                  found = true;
-                  break;
-                }
-              }
-              if (found) newState[game.id] = true;
-            }
+      await for (final result in scanner.sync(romsRoot)) {
+        if (result.romId != null) {
+          // Found a match!
+          final newState = Map<String, bool>.from(state);
+          newState[result.romId!] = true;
+          state = newState;
+
+          // If we have the game object, cache it for offline use
+          if (result.game != null) {
+            await metadataCache.saveGames([result.game!]);
           }
-
-          // Update state incrementally so UI sees progress
-          state = Map<String, bool>.from(newState);
-
-          offset += limit;
-          if (offset >= result.total) break;
-          
-          // Small gap to not overwhelm the server
-          await Future.delayed(const Duration(milliseconds: 500));
         }
       }
     } catch (e) {
-      debugPrint('[DownloadedGamesCache] Deep scan error: $e');
+      debugPrint('[DownloadedGamesCache] Sync error: $e');
     } finally {
-      _isDeepScanning = false;
-      debugPrint('[DownloadedGamesCache] Background deep scan complete.');
+      _isSyncing = false;
+      debugPrint('[DownloadedGamesCache] Incremental sync complete.');
     }
   }
 
diff --git a/lib/providers/romm_provider.dart b/lib/providers/romm_provider.dart
index 803aa5c..f26fbdb 100644
--- a/lib/providers/romm_provider.dart
+++ b/lib/providers/romm_provider.dart
@@ -11,6 +11,8 @@ import 'package:freegosy/core/emulator/emulator_registry_data.dart';
 
 import 'package:freegosy/core/storage/download_cache_service.dart';
 import 'package:freegosy/core/storage/metadata_cache_service.dart';
+import 'package:freegosy/core/storage/rom_mapping_service.dart';
+import 'package:freegosy/core/romm/rom_scanner_service.dart';
 
 import 'package:freegosy/core/emulator/firmware_service.dart';
 
@@ -164,3 +166,19 @@ final metadataCacheServiceProvider = FutureProvider<MetadataCacheService>((ref)
   await service.load();
   return service;
 });
+
+final romMappingServiceProvider = FutureProvider<RomMappingService>((ref) async {
+  final service = RomMappingService();
+  await service.init();
+  return service;
+});
+
+final romScannerServiceProvider = Provider<RomScannerService?>((ref) {
+  final rommService = ref.watch(rommServiceProvider);
+  final mappingServiceAsync = ref.watch(romMappingServiceProvider);
+  
+  if (rommService != null && mappingServiceAsync.hasValue) {
+    return RomScannerService(rommService, mappingServiceAsync.value!);
+  }
+  return null;
+});
diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart
index e28893c..3b62696 100644
--- a/lib/ui/screens/library_screen.dart
+++ b/lib/ui/screens/library_screen.dart
@@ -150,7 +150,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> with LibraryActio
     final rommConfigAsync = ref.watch(rommConfigProvider);
     final directoryServiceAsync = ref.watch(directoryServiceProvider);
     final downloadedCache = ref.watch(downloadedGamesCacheProvider);
-    final isDeepScanning = ref.watch(downloadedGamesCacheProvider.notifier).isDeepScanning;
+    final isSyncing = ref.watch(downloadedGamesCacheProvider.notifier).isSyncing;
 
     // Trigger initial load once service becomes available
     ref.listen(rommServiceProvider, (prev, next) {
@@ -249,7 +249,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> with LibraryActio
         child: ExcludeSemantics(
           child: Column(
             children: [
-              if (isDeepScanning)
+              if (isSyncing)
                 const LinearProgressIndicator(
                   minHeight: 2,
                   backgroundColor: Colors.transparent,
diff --git a/pubspec.lock b/pubspec.lock
index 959ec29..32610cd 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -424,6 +424,22 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.3.2"
+  hive:
+    dependency: "direct main"
+    description:
+      name: hive
+      sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.3"
+  hive_flutter:
+    dependency: "direct main"
+    description:
+      name: hive_flutter
+      sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
   hooks:
     dependency: transitive
     description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 24c5d80..f240786 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -48,6 +48,8 @@ dependencies:
   path: ^1.9.0
   flutter_secure_storage: ^9.2.4
   url_launcher: ^6.3.2
+  hive: ^2.2.3
+  hive_flutter: ^1.1.0
 
 dev_dependencies:
   flutter_test:
diff --git a/test/unit/firmware_service_test.mocks.dart b/test/unit/firmware_service_test.mocks.dart
index 39ed124..8903990 100644
--- a/test/unit/firmware_service_test.mocks.dart
+++ b/test/unit/firmware_service_test.mocks.dart
@@ -145,6 +145,22 @@ class MockRommService extends _i1.Mock implements _i5.RommService {
           )
           as _i7.Future<List<_i2.Game>>);
 
+  @override
+  _i7.Future<List<_i2.Game>> searchRoms({
+    String? sha1,
+    String? md5,
+    String? search,
+  }) =>
+      (super.noSuchMethod(
+            Invocation.method(#searchRoms, [], {
+              #sha1: sha1,
+              #md5: md5,
+              #search: search,
+            }),
+            returnValue: _i7.Future<List<_i2.Game>>.value(<_i2.Game>[]),
+          )
+          as _i7.Future<List<_i2.Game>>);
+
   @override
   _i7.Future<({List<_i2.Game> games, int total})> getGamesPage({
     int? offset = 0,
@@ -231,6 +247,14 @@ class MockRommService extends _i1.Mock implements _i5.RommService {
           )
           as _i7.Future<bool>);
 
+  @override
+  _i7.Future<bool> deleteSaves(List<int>? saveIds) =>
+      (super.noSuchMethod(
+            Invocation.method(#deleteSaves, [saveIds]),
+            returnValue: _i7.Future<bool>.value(false),
+          )
+          as _i7.Future<bool>);
+
   @override
   _i7.Future<void> pruneOldSaves(String? gameId, {int? keepCount = 5}) =>
       (super.noSuchMethod(
diff --git a/test/unit/paginated_games_provider_test.mocks.dart b/test/unit/paginated_games_provider_test.mocks.dart
index 09e9203..6e7b987 100644
--- a/test/unit/paginated_games_provider_test.mocks.dart
+++ b/test/unit/paginated_games_provider_test.mocks.dart
@@ -129,6 +129,22 @@ class MockRommService extends _i1.Mock implements _i3.RommService {
           )
           as _i5.Future<List<_i2.Game>>);
 
+  @override
+  _i5.Future<List<_i2.Game>> searchRoms({
+    String? sha1,
+    String? md5,
+    String? search,
+  }) =>
+      (super.noSuchMethod(
+            Invocation.method(#searchRoms, [], {
+              #sha1: sha1,
+              #md5: md5,
+              #search: search,
+            }),
+            returnValue: _i5.Future<List<_i2.Game>>.value(<_i2.Game>[]),
+          )
+          as _i5.Future<List<_i2.Game>>);
+
   @override
   _i5.Future<({List<_i2.Game> games, int total})> getGamesPage({
     int? offset = 0,
@@ -215,6 +231,14 @@ class MockRommService extends _i1.Mock implements _i3.RommService {
           )
           as _i5.Future<bool>);
 
+  @override
+  _i5.Future<bool> deleteSaves(List<int>? saveIds) =>
+      (super.noSuchMethod(
+            Invocation.method(#deleteSaves, [saveIds]),
+            returnValue: _i5.Future<bool>.value(false),
+          )
+          as _i5.Future<bool>);
+
   @override
   _i5.Future<void> pruneOldSaves(String? gameId, {int? keepCount = 5}) =>
       (super.noSuchMethod(
diff --git a/test/unit/save_sync_service_test.mocks.dart b/test/unit/save_sync_service_test.mocks.dart
index 73a7aa7..9d651f3 100644
--- a/test/unit/save_sync_service_test.mocks.dart
+++ b/test/unit/save_sync_service_test.mocks.dart
@@ -145,6 +145,22 @@ class MockRommService extends _i1.Mock implements _i5.RommService {
           )
           as _i7.Future<List<_i2.Game>>);
 
+  @override
+  _i7.Future<List<_i2.Game>> searchRoms({
+    String? sha1,
+    String? md5,
+    String? search,
+  }) =>
+      (super.noSuchMethod(
+            Invocation.method(#searchRoms, [], {
+              #sha1: sha1,
+              #md5: md5,
+              #search: search,
+            }),
+            returnValue: _i7.Future<List<_i2.Game>>.value(<_i2.Game>[]),
+          )
+          as _i7.Future<List<_i2.Game>>);
+
   @override
   _i7.Future<({List<_i2.Game> games, int total})> getGamesPage({
     int? offset = 0,
@@ -231,6 +247,14 @@ class MockRommService extends _i1.Mock implements _i5.RommService {
           )
           as _i7.Future<bool>);
 
+  @override
+  _i7.Future<bool> deleteSaves(List<int>? saveIds) =>
+      (super.noSuchMethod(
+            Invocation.method(#deleteSaves, [saveIds]),
+            returnValue: _i7.Future<bool>.value(false),
+          )
+          as _i7.Future<bool>);
+
   @override
   _i7.Future<void> pruneOldSaves(String? gameId, {int? keepCount = 5}) =>
       (super.noSuchMethod(
diff --git a/test/widgets/library_screen_test.dart b/test/widgets/library_screen_test.dart
index 36461e4..bccd216 100644
--- a/test/widgets/library_screen_test.dart
+++ b/test/widgets/library_screen_test.dart
@@ -10,21 +10,27 @@ import 'package:freegosy/ui/screens/library_screen.dart';
 import 'package:mockito/annotations.dart';
 import 'package:mockito/mockito.dart';
 
+import 'package:freegosy/core/storage/rom_mapping_service.dart';
 import 'library_screen_test.mocks.dart';
 
-@GenerateMocks([RommService, DirectoryService])
+@GenerateMocks([RommService, DirectoryService, RomMappingService])
 void main() {
   late MockRommService mockRommService;
   late MockDirectoryService mockDirectoryService;
+  late MockRomMappingService mockRomMappingService;
 
   setUp(() {
     mockRommService = MockRommService();
     mockDirectoryService = MockDirectoryService();
+    mockRomMappingService = MockRomMappingService();
     
     when(mockRommService.config).thenReturn(RomMConfig(baseUrl: 'https://test.com', username: 'u', password: 'p'));
     when(mockRommService.resolveCoverUrl(any)).thenReturn(null);
     when(mockRommService.getRecentlyPlayed(limit: anyNamed('limit'))).thenAnswer((_) async => []);
+    when(mockRommService.searchRoms(search: anyNamed('search'))).thenAnswer((_) async => []);
     when(mockDirectoryService.status).thenReturn(const StorageStatus());
+    when(mockRomMappingService.getMappings()).thenReturn({});
+    when(mockRomMappingService.getMTimes()).thenReturn({});
   });
 
   group('LibraryScreen', () {
@@ -34,6 +40,8 @@ void main() {
       await tester.pumpWidget(ProviderScope(
         overrides: [
           rommServiceProvider.overrideWithValue(mockRommService),
+          romMappingServiceProvider.overrideWith((ref) => Future.value(mockRomMappingService)),
+          romScannerServiceProvider.overrideWithValue(null),
           directoryServiceProvider.overrideWith((ref) => Future.value(mockDirectoryService)),
           paginatedGamesProvider.overrideWith((ref) => PaginatedGamesNotifier(ref)..state = const PaginatedGamesState(isLoading: true)),
         ],
@@ -57,6 +65,8 @@ void main() {
       await tester.pumpWidget(ProviderScope(
         overrides: [
           rommServiceProvider.overrideWithValue(mockRommService),
+          romMappingServiceProvider.overrideWith((ref) => Future.value(mockRomMappingService)),
+          romScannerServiceProvider.overrideWithValue(null),
           directoryServiceProvider.overrideWith((ref) => Future.value(mockDirectoryService)),
           paginatedGamesProvider.overrideWith((ref) => PaginatedGamesNotifier(ref)..state = PaginatedGamesState(games: games, total: 2, hasMore: false)),
         ],
@@ -77,6 +87,8 @@ void main() {
       await tester.pumpWidget(ProviderScope(
         overrides: [
           rommServiceProvider.overrideWithValue(mockRommService),
+          romMappingServiceProvider.overrideWith((ref) => Future.value(mockRomMappingService)),
+          romScannerServiceProvider.overrideWithValue(null),
           directoryServiceProvider.overrideWith((ref) => Future.value(mockDirectoryService)),
           paginatedGamesProvider.overrideWith((ref) => PaginatedGamesNotifier(ref)..state = const PaginatedGamesState(games: [], total: 0, hasMore: false)),
         ],
@@ -95,6 +107,8 @@ void main() {
       await tester.pumpWidget(ProviderScope(
         overrides: [
           rommServiceProvider.overrideWithValue(mockRommService),
+          romMappingServiceProvider.overrideWith((ref) => Future.value(mockRomMappingService)),
+          romScannerServiceProvider.overrideWithValue(null),
           directoryServiceProvider.overrideWith((ref) => Future.value(mockDirectoryService)),
           paginatedGamesProvider.overrideWith((ref) => PaginatedGamesNotifier(ref)..state = const PaginatedGamesState(error: 'Connection Failed')),
         ],
diff --git a/test/widgets/library_screen_test.mocks.dart b/test/widgets/library_screen_test.mocks.dart
index 5c695ba..1f326cc 100644
--- a/test/widgets/library_screen_test.mocks.dart
+++ b/test/widgets/library_screen_test.mocks.dart
@@ -12,6 +12,7 @@ import 'package:freegosy/core/emulator/linux_strategies/linux_environment_strate
 import 'package:freegosy/core/romm/romm_models.dart' as _i2;
 import 'package:freegosy/core/romm/romm_service.dart' as _i5;
 import 'package:freegosy/core/storage/directory_service.dart' as _i3;
+import 'package:freegosy/core/storage/rom_mapping_service.dart' as _i10;
 import 'package:mockito/mockito.dart' as _i1;
 import 'package:mockito/src/dummies.dart' as _i6;
 
@@ -143,6 +144,22 @@ class MockRommService extends _i1.Mock implements _i5.RommService {
           )
           as _i7.Future<List<_i2.Game>>);
 
+  @override
+  _i7.Future<List<_i2.Game>> searchRoms({
+    String? sha1,
+    String? md5,
+    String? search,
+  }) =>
+      (super.noSuchMethod(
+            Invocation.method(#searchRoms, [], {
+              #sha1: sha1,
+              #md5: md5,
+              #search: search,
+            }),
+            returnValue: _i7.Future<List<_i2.Game>>.value(<_i2.Game>[]),
+          )
+          as _i7.Future<List<_i2.Game>>);
+
   @override
   _i7.Future<({List<_i2.Game> games, int total})> getGamesPage({
     int? offset = 0,
@@ -229,6 +246,14 @@ class MockRommService extends _i1.Mock implements _i5.RommService {
           )
           as _i7.Future<bool>);
 
+  @override
+  _i7.Future<bool> deleteSaves(List<int>? saveIds) =>
+      (super.noSuchMethod(
+            Invocation.method(#deleteSaves, [saveIds]),
+            returnValue: _i7.Future<bool>.value(false),
+          )
+          as _i7.Future<bool>);
+
   @override
   _i7.Future<void> pruneOldSaves(String? gameId, {int? keepCount = 5}) =>
       (super.noSuchMethod(
@@ -857,3 +882,87 @@ class MockDirectoryService extends _i1.Mock implements _i3.DirectoryService {
           )
           as _i7.Future<void>);
 }
+
+/// A class which mocks [RomMappingService].
+///
+/// See the documentation for Mockito's code generation for more information.
+class MockRomMappingService extends _i1.Mock implements _i10.RomMappingService {
+  MockRomMappingService() {
+    _i1.throwOnMissingStub(this);
+  }
+
+  @override
+  _i7.Future<void> init() =>
+      (super.noSuchMethod(
+            Invocation.method(#init, []),
+            returnValue: _i7.Future<void>.value(),
+            returnValueForMissingStub: _i7.Future<void>.value(),
+          )
+          as _i7.Future<void>);
+
+  @override
+  Map<String, String> getMappings() =>
+      (super.noSuchMethod(
+            Invocation.method(#getMappings, []),
+            returnValue: <String, String>{},
+          )
+          as Map<String, String>);
+
+  @override
+  _i7.Future<void> saveMappings(Map<String, String>? mappings) =>
+      (super.noSuchMethod(
+            Invocation.method(#saveMappings, [mappings]),
+            returnValue: _i7.Future<void>.value(),
+            returnValueForMissingStub: _i7.Future<void>.value(),
+          )
+          as _i7.Future<void>);
+
+  @override
+  _i7.Future<void> updateMapping(String? path, String? romId) =>
+      (super.noSuchMethod(
+            Invocation.method(#updateMapping, [path, romId]),
+            returnValue: _i7.Future<void>.value(),
+            returnValueForMissingStub: _i7.Future<void>.value(),
+          )
+          as _i7.Future<void>);
+
+  @override
+  Map<String, int> getMTimes() =>
+      (super.noSuchMethod(
+            Invocation.method(#getMTimes, []),
+            returnValue: <String, int>{},
+          )
+          as Map<String, int>);
+
+  @override
+  _i7.Future<void> saveMTimes(Map<String, int>? mtimes) =>
+      (super.noSuchMethod(
+            Invocation.method(#saveMTimes, [mtimes]),
+            returnValue: _i7.Future<void>.value(),
+            returnValueForMissingStub: _i7.Future<void>.value(),
+          )
+          as _i7.Future<void>);
+
+  @override
+  _i7.Future<void> setLastSyncTime(int? timestamp) =>
+      (super.noSuchMethod(
+            Invocation.method(#setLastSyncTime, [timestamp]),
+            returnValue: _i7.Future<void>.value(),
+            returnValueForMissingStub: _i7.Future<void>.value(),
+          )
+          as _i7.Future<void>);
+
+  @override
+  String? getRomIdForPath(String? path) =>
+      (super.noSuchMethod(Invocation.method(#getRomIdForPath, [path]))
+          as String?);
+
+  @override
+  _i7.Future<void> clear() =>
+      (super.noSuchMethod(
+            Invocation.method(#clear, []),
+            returnValue: _i7.Future<void>.value(),
+            returnValueForMissingStub: _i7.Future<void>.value(),
+          )
+          as _i7.Future<void>);
+}
diff --git a/test/widgets/settings_screen_test.mocks.dart b/test/widgets/settings_screen_test.mocks.dart
index db64fda..0e4c4d6 100644
--- a/test/widgets/settings_screen_test.mocks.dart
+++ b/test/widgets/settings_screen_test.mocks.dart
@@ -647,6 +647,22 @@ class MockRommService extends _i1.Mock implements _i8.RommService {
           )
           as _i6.Future<List<_i4.Game>>);
 
+  @override
+  _i6.Future<List<_i4.Game>> searchRoms({
+    String? sha1,
+    String? md5,
+    String? search,
+  }) =>
+      (super.noSuchMethod(
+            Invocation.method(#searchRoms, [], {
+              #sha1: sha1,
+              #md5: md5,
+              #search: search,
+            }),
+            returnValue: _i6.Future<List<_i4.Game>>.value(<_i4.Game>[]),
+          )
+          as _i6.Future<List<_i4.Game>>);
+
   @override
   _i6.Future<({List<_i4.Game> games, int total})> getGamesPage({
     int? offset = 0,
@@ -733,6 +749,14 @@ class MockRommService extends _i1.Mock implements _i8.RommService {
           )
           as _i6.Future<bool>);
 
+  @override
+  _i6.Future<bool> deleteSaves(List<int>? saveIds) =>
+      (super.noSuchMethod(
+            Invocation.method(#deleteSaves, [saveIds]),
+            returnValue: _i6.Future<bool>.value(false),
+          )
+          as _i6.Future<bool>);
+
   @override
   _i6.Future<void> pruneOldSaves(String? gameId, {int? keepCount = 5}) =>
       (super.noSuchMethod(

Clone this wiki locally