diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index e7369fb..2678ffe 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -15,6 +15,8 @@ abstract interface class AppDependencies { WorkoutHistoryUseCases get workoutHistoryUseCases; ProgressionStatsUseCase get progressionStatsUseCase; ExercisePerformanceReferenceUseCase get exercisePerformanceReferenceUseCase; + DataExportUseCase get dataExportUseCase; + DataImportUseCase get dataImportUseCase; SyncUseCases get syncUseCases; ShareUseCases get shareUseCases; } @@ -33,6 +35,8 @@ final class AppBootstrap implements AppDependencies { required this.workoutHistoryUseCases, required this.progressionStatsUseCase, required this.exercisePerformanceReferenceUseCase, + required this.dataExportUseCase, + required this.dataImportUseCase, required this.syncUseCases, required this.shareUseCases, required this.syncGateway, @@ -62,6 +66,10 @@ final class AppBootstrap implements AppDependencies { @override final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase; @override + final DataExportUseCase dataExportUseCase; + @override + final DataImportUseCase dataImportUseCase; + @override final SyncUseCases syncUseCases; @override final ShareUseCases shareUseCases; @@ -90,6 +98,8 @@ final class AppBootstrap implements AppDependencies { ); final performanceReferenceRepository = DriftExercisePerformanceReferenceRepository(database); + final localDataBackupRepository = DriftLocalDataBackupRepository(database); + const localBackupMediaStore = PathProviderLocalMediaStorage(); final ids = LocalIdGenerator(); const clock = SystemClock(); const originDeviceId = 'local-device'; @@ -179,6 +189,16 @@ final class AppBootstrap implements AppDependencies { exercisePerformanceReferenceUseCase: ExercisePerformanceReferenceUseCase( repository: performanceReferenceRepository, ), + dataExportUseCase: DataExportUseCase( + repository: localDataBackupRepository, + mediaStore: localBackupMediaStore, + clock: clock, + ), + dataImportUseCase: DataImportUseCase( + repository: localDataBackupRepository, + mediaStore: localBackupMediaStore, + clock: clock, + ), syncUseCases: SyncUseCases( tokenStore: const SecureStorageAuthTokenStore(), remoteSyncApi: remoteSyncApi, diff --git a/lib/application/ports.dart b/lib/application/ports.dart index c04e75d..90d7d72 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import '../domain/domain.dart'; abstract interface class Clock { @@ -34,6 +36,213 @@ final class TagUsage { final int count; } +enum LocalBackupImportMode { merge, replaceAll } + +enum LocalBackupValidationError { + invalidFile, + incompatibleFormat, + newerVersion, + corrupted, + activeWorkoutInProgress, + importFailedNoMutation, +} + +final class LocalBackupException implements Exception { + const LocalBackupException(this.error); + + final LocalBackupValidationError error; + + @override + String toString() => error.name; +} + +final class LocalBackupDocument { + const LocalBackupDocument({required this.fileName, required this.bytes}); + + final String fileName; + final Uint8List bytes; +} + +final class LocalBackupCounts { + const LocalBackupCounts({ + required this.exercises, + required this.programs, + required this.workoutTemplates, + required this.workoutHistories, + required this.mediaAssets, + required this.embeddedMediaFiles, + }); + + final int exercises; + final int programs; + final int workoutTemplates; + final int workoutHistories; + final int mediaAssets; + final int embeddedMediaFiles; +} + +final class LocalBackupPreview { + const LocalBackupPreview({ + required this.exportedAt, + required this.counts, + required this.hasEmbeddedMedia, + required this.missingMediaCount, + required this.hasNameDuplicates, + }); + + final DateTime exportedAt; + final LocalBackupCounts counts; + final bool hasEmbeddedMedia; + final int missingMediaCount; + final bool hasNameDuplicates; +} + +final class LocalBackupImportResult { + const LocalBackupImportResult({ + required this.insertedCount, + required this.updatedCount, + required this.ignoredOlderCount, + required this.deletedByReplaceCount, + required this.missingMediaCount, + }); + + final int insertedCount; + final int updatedCount; + final int ignoredOlderCount; + final int deletedByReplaceCount; + final int missingMediaCount; + + LocalBackupImportResult copyWith({int? missingMediaCount}) { + return LocalBackupImportResult( + insertedCount: insertedCount, + updatedCount: updatedCount, + ignoredOlderCount: ignoredOlderCount, + deletedByReplaceCount: deletedByReplaceCount, + missingMediaCount: missingMediaCount ?? this.missingMediaCount, + ); + } +} + +final class LocalBackupResource { + const LocalBackupResource({ + required this.id, + required this.updatedAt, + required this.payload, + }); + + final String id; + final DateTime updatedAt; + final Map payload; + + LocalBackupResource copyWith({Map? payload}) { + return LocalBackupResource( + id: id, + updatedAt: updatedAt, + payload: payload ?? this.payload, + ); + } +} + +final class LocalDataExportSnapshot { + const LocalDataExportSnapshot({ + required this.exportedAt, + required this.appSchemaVersion, + required this.originDeviceId, + required this.mediaAssets, + required this.exercises, + required this.programs, + required this.workoutTemplates, + required this.workoutHistories, + this.mediaFiles = const [], + }); + + final DateTime exportedAt; + final int appSchemaVersion; + final String originDeviceId; + final List mediaAssets; + final List exercises; + final List programs; + final List workoutTemplates; + final List workoutHistories; + final List mediaFiles; + + LocalBackupCounts get counts => LocalBackupCounts( + exercises: exercises.length, + programs: programs.length, + workoutTemplates: workoutTemplates.length, + workoutHistories: workoutHistories.length, + mediaAssets: mediaAssets.length, + embeddedMediaFiles: mediaFiles.length, + ); + + LocalDataExportSnapshot copyWith({ + List? mediaAssets, + List? mediaFiles, + }) { + return LocalDataExportSnapshot( + exportedAt: exportedAt, + appSchemaVersion: appSchemaVersion, + originDeviceId: originDeviceId, + mediaAssets: mediaAssets ?? this.mediaAssets, + exercises: exercises, + programs: programs, + workoutTemplates: workoutTemplates, + workoutHistories: workoutHistories, + mediaFiles: mediaFiles ?? this.mediaFiles, + ); + } +} + +final class EmbeddedBackupMediaFile { + const EmbeddedBackupMediaFile({ + required this.mediaAssetId, + required this.role, + required this.fileName, + required this.mimeType, + required this.sizeBytes, + required this.base64, + }); + + final String mediaAssetId; + final String role; + final String fileName; + final String? mimeType; + final int sizeBytes; + final String base64; +} + +final class RestoredMediaFile { + const RestoredMediaFile({ + required this.mediaAssetId, + required this.localUri, + required this.sizeBytes, + }); + + final String mediaAssetId; + final String localUri; + final int sizeBytes; +} + +abstract interface class LocalDataBackupRepository { + Future readExportSnapshot(DateTime exportedAt); + Future hasOpenActiveWorkoutSession(); + Future hasAnyUserData(); + Future applyImportSnapshot({ + required LocalDataExportSnapshot snapshot, + required LocalBackupImportMode mode, + required DateTime importedAt, + }); +} + +abstract interface class LocalBackupMediaStore { + Future> readEmbeddableFiles( + List mediaAssets, + ); + Future> restoreEmbeddedFiles( + List files, + ); +} + enum ProgressionPeriod { fourWeeks, threeMonths, all } enum ProgressionMeasure { manualScore, stopwatchScore, reps, time } diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 74cb851..ec539ef 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import '../domain/domain.dart'; import 'ports.dart'; @@ -49,6 +50,175 @@ List tagSuggestionsFor( return suggestions; } +final class LocalBackupCodec { + const LocalBackupCodec(); + + static const kind = 'gametime.localBackup'; + static const formatVersion = 1; + static const minSupportedFormatVersion = 1; + + Uint8List encode(LocalDataExportSnapshot snapshot) { + final document = { + 'kind': kind, + 'formatVersion': formatVersion, + 'minSupportedFormatVersion': minSupportedFormatVersion, + 'exportedAt': snapshot.exportedAt.toUtc().toIso8601String(), + 'appSchemaVersion': snapshot.appSchemaVersion, + 'originDeviceId': snapshot.originDeviceId, + 'counts': _countsToJson(snapshot.counts), + 'data': { + 'mediaAssets': snapshot.mediaAssets + .map((resource) => resource.payload) + .toList(), + 'exercises': snapshot.exercises + .map((resource) => resource.payload) + .toList(), + 'programs': snapshot.programs + .map((resource) => resource.payload) + .toList(), + 'workoutTemplates': snapshot.workoutTemplates + .map((resource) => resource.payload) + .toList(), + 'workoutHistories': snapshot.workoutHistories + .map((resource) => resource.payload) + .toList(), + }, + 'mediaFiles': snapshot.mediaFiles.map(_mediaFileToJson).toList(), + }; + return Uint8List.fromList(utf8.encode(jsonEncode(document))); + } + + LocalDataExportSnapshot decode(Uint8List bytes) { + final Object? decoded; + try { + decoded = jsonDecode(utf8.decode(bytes)); + } on FormatException { + throw const LocalBackupException(LocalBackupValidationError.corrupted); + } on Object { + throw const LocalBackupException(LocalBackupValidationError.invalidFile); + } + if (decoded is! Map) { + throw const LocalBackupException(LocalBackupValidationError.invalidFile); + } + final root = Map.from(decoded); + if (root['kind'] != kind) { + throw const LocalBackupException(LocalBackupValidationError.invalidFile); + } + final version = root['formatVersion']; + final minSupported = root['minSupportedFormatVersion']; + if (version is! int || minSupported is! int || version < 1) { + throw const LocalBackupException( + LocalBackupValidationError.incompatibleFormat, + ); + } + if (version > formatVersion || minSupported > formatVersion) { + throw const LocalBackupException(LocalBackupValidationError.newerVersion); + } + final data = root['data']; + if (data is! Map) { + throw const LocalBackupException(LocalBackupValidationError.corrupted); + } + final dataMap = Map.from(data); + return LocalDataExportSnapshot( + exportedAt: _dateTimeFromBackup(root['exportedAt']), + appSchemaVersion: root['appSchemaVersion'] as int? ?? 1, + originDeviceId: root['originDeviceId'] as String? ?? 'local-device', + mediaAssets: _resourcesFromBackup(dataMap, 'mediaAssets'), + exercises: _resourcesFromBackup(dataMap, 'exercises'), + programs: _resourcesFromBackup(dataMap, 'programs'), + workoutTemplates: _resourcesFromBackup(dataMap, 'workoutTemplates'), + workoutHistories: _resourcesFromBackup(dataMap, 'workoutHistories'), + mediaFiles: _mediaFilesFromBackup(root['mediaFiles']), + ); + } + + LocalBackupPreview preview(Uint8List bytes) { + final snapshot = decode(bytes); + return LocalBackupPreview( + exportedAt: snapshot.exportedAt, + counts: snapshot.counts, + hasEmbeddedMedia: snapshot.mediaFiles.isNotEmpty, + missingMediaCount: _missingMediaCount(snapshot), + hasNameDuplicates: _hasNameDuplicates(snapshot), + ); + } +} + +final class DataExportUseCase { + const DataExportUseCase({ + required this.repository, + required this.mediaStore, + required this.clock, + this.codec = const LocalBackupCodec(), + }); + + final LocalDataBackupRepository repository; + final LocalBackupMediaStore mediaStore; + final Clock clock; + final LocalBackupCodec codec; + + Future exportAll() async { + final exportedAt = clock.now(); + final snapshot = await repository.readExportSnapshot(exportedAt); + final mediaFiles = await mediaStore.readEmbeddableFiles( + snapshot.mediaAssets, + ); + final completeSnapshot = snapshot.copyWith(mediaFiles: mediaFiles); + return LocalBackupDocument( + fileName: _localBackupFileName(exportedAt), + bytes: codec.encode(completeSnapshot), + ); + } +} + +final class DataImportUseCase { + const DataImportUseCase({ + required this.repository, + required this.mediaStore, + required this.clock, + this.codec = const LocalBackupCodec(), + }); + + final LocalDataBackupRepository repository; + final LocalBackupMediaStore mediaStore; + final Clock clock; + final LocalBackupCodec codec; + + Future preview(Uint8List bytes) async { + return codec.preview(bytes); + } + + Future importFrom( + Uint8List bytes, { + required LocalBackupImportMode mode, + }) async { + if (await repository.hasOpenActiveWorkoutSession()) { + throw const LocalBackupException( + LocalBackupValidationError.activeWorkoutInProgress, + ); + } + final decoded = codec.decode(bytes); + final restoredMediaFiles = await mediaStore.restoreEmbeddedFiles( + decoded.mediaFiles, + ); + final snapshot = _withRestoredMediaUris(decoded, restoredMediaFiles); + try { + final result = await repository.applyImportSnapshot( + snapshot: snapshot, + mode: mode, + importedAt: clock.now(), + ); + return result.copyWith(missingMediaCount: _missingMediaCount(snapshot)); + } on LocalBackupException { + rethrow; + } on Object { + throw const LocalBackupException( + LocalBackupValidationError.importFailedNoMutation, + ); + } + } +} + enum StarterSeedStatus { inserted, skippedAlreadyApplied, skippedNotEmpty } final class StarterSeedResult { @@ -4714,3 +4884,160 @@ String _copyName(String sourceName, Iterable existingNames) { copyNumber += 1; } } + +Map _countsToJson(LocalBackupCounts counts) => { + 'exercises': counts.exercises, + 'programs': counts.programs, + 'workoutTemplates': counts.workoutTemplates, + 'workoutHistories': counts.workoutHistories, + 'mediaAssets': counts.mediaAssets, + 'embeddedMediaFiles': counts.embeddedMediaFiles, +}; + +Map _mediaFileToJson(EmbeddedBackupMediaFile file) => { + 'mediaAssetId': file.mediaAssetId, + 'role': file.role, + 'fileName': file.fileName, + 'mimeType': file.mimeType, + 'sizeBytes': file.sizeBytes, + 'base64': file.base64, +}; + +List _resourcesFromBackup( + Map data, + String key, +) { + final value = data[key]; + if (value is! List) { + throw const LocalBackupException(LocalBackupValidationError.corrupted); + } + return value + .map((item) { + if (item is! Map) { + throw const LocalBackupException( + LocalBackupValidationError.corrupted, + ); + } + final payload = Map.from(item); + return LocalBackupResource( + id: _stringFromBackup(payload, 'id'), + updatedAt: _updatedAtFromBackupPayload(payload), + payload: payload, + ); + }) + .toList(growable: false); +} + +List _mediaFilesFromBackup(Object? value) { + if (value == null) { + return const []; + } + if (value is! List) { + throw const LocalBackupException(LocalBackupValidationError.corrupted); + } + return value + .map((item) { + if (item is! Map) { + throw const LocalBackupException( + LocalBackupValidationError.corrupted, + ); + } + final payload = Map.from(item); + return EmbeddedBackupMediaFile( + mediaAssetId: _stringFromBackup(payload, 'mediaAssetId'), + role: payload['role'] as String? ?? 'original', + fileName: _stringFromBackup(payload, 'fileName'), + mimeType: payload['mimeType'] as String?, + sizeBytes: payload['sizeBytes'] as int? ?? 0, + base64: _stringFromBackup(payload, 'base64'), + ); + }) + .toList(growable: false); +} + +String _stringFromBackup(Map payload, String key) { + final value = payload[key]; + if (value is String && value.isNotEmpty) { + return value; + } + throw const LocalBackupException(LocalBackupValidationError.corrupted); +} + +DateTime _updatedAtFromBackupPayload(Map payload) { + final metadata = payload['metadata']; + if (metadata is! Map) { + throw const LocalBackupException(LocalBackupValidationError.corrupted); + } + return _dateTimeFromBackup(Map.from(metadata)['updatedAt']); +} + +DateTime _dateTimeFromBackup(Object? value) { + if (value is! String) { + throw const LocalBackupException(LocalBackupValidationError.corrupted); + } + final parsed = DateTime.tryParse(value); + if (parsed == null) { + throw const LocalBackupException(LocalBackupValidationError.corrupted); + } + return parsed.toUtc(); +} + +int _missingMediaCount(LocalDataExportSnapshot snapshot) { + final embeddedIds = snapshot.mediaFiles + .map((file) => file.mediaAssetId) + .toSet(); + return snapshot.mediaAssets + .where((asset) => !embeddedIds.contains(asset.id)) + .length; +} + +bool _hasNameDuplicates(LocalDataExportSnapshot snapshot) { + return _hasDuplicatesByName(snapshot.exercises) || + _hasDuplicatesByName(snapshot.programs) || + _hasDuplicatesByName(snapshot.workoutTemplates); +} + +bool _hasDuplicatesByName(List resources) { + final names = {}; + for (final resource in resources) { + final name = resource.payload['name']; + if (name is! String) { + continue; + } + if (!names.add(name.trim().toLowerCase())) { + return true; + } + } + return false; +} + +LocalDataExportSnapshot _withRestoredMediaUris( + LocalDataExportSnapshot snapshot, + Map restoredFiles, +) { + if (restoredFiles.isEmpty) { + return snapshot; + } + final mediaAssets = [ + for (final asset in snapshot.mediaAssets) + if (restoredFiles[asset.id] case final restored?) + asset.copyWith( + payload: { + ...asset.payload, + 'localUri': restored.localUri, + 'sizeBytes': restored.sizeBytes, + }, + ) + else + asset, + ]; + return snapshot.copyWith(mediaAssets: mediaAssets); +} + +String _localBackupFileName(DateTime exportedAt) { + final utc = exportedAt.toUtc(); + final year = utc.year.toString().padLeft(4, '0'); + final month = utc.month.toString().padLeft(2, '0'); + final day = utc.day.toString().padLeft(2, '0'); + return 'gametime-sauvegarde-$year-$month-$day.gametime'; +} diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index aeaf96a..bffec9c 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -1602,6 +1602,390 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { } } +final class DriftLocalDataBackupRepository + implements LocalDataBackupRepository { + const DriftLocalDataBackupRepository(this.database); + + final db.AppDatabase database; + + @override + Future readExportSnapshot( + DateTime exportedAt, + ) async { + final mediaAssets = await DriftMediaAssetRepository(database).listActive(); + final exercises = await DriftExerciseRepository(database).listActive(); + final programs = await DriftProgramRepository(database).listActive(); + final templates = await DriftWorkoutTemplateRepository( + database, + ).listActive(); + final histories = await DriftWorkoutHistoryRepository( + database, + ).listActive(); + return LocalDataExportSnapshot( + exportedAt: exportedAt.toUtc(), + appSchemaVersion: database.schemaVersion, + originDeviceId: 'local-device', + mediaAssets: [ + for (final asset in mediaAssets) + _backupResource(asset.metadata, _mediaAssetPayload(asset)), + ], + exercises: [ + for (final exercise in exercises) + _backupResource(exercise.metadata, _exercisePayload(exercise)), + ], + programs: [ + for (final program in programs) + _backupResource(program.metadata, _programPayload(program)), + ], + workoutTemplates: [ + for (final template in templates) + _backupResource(template.metadata, _workoutTemplatePayload(template)), + ], + workoutHistories: [ + for (final history in histories) + _backupResource( + history.metadata, + _localWorkoutHistoryPayload(history), + ), + ], + ); + } + + @override + Future hasOpenActiveWorkoutSession() async { + return DriftActiveSessionRepository( + database, + ).findOpen().then((session) => session != null); + } + + @override + Future hasAnyUserData() async { + final count = await database.customSelect(''' +SELECT + (SELECT COUNT(*) FROM media_assets WHERE deleted_at IS NULL) + + (SELECT COUNT(*) FROM exercises WHERE deleted_at IS NULL AND archived_at IS NULL) + + (SELECT COUNT(*) FROM programs WHERE deleted_at IS NULL) + + (SELECT COUNT(*) FROM workout_templates WHERE deleted_at IS NULL) + + (SELECT COUNT(*) FROM workout_history WHERE deleted_at IS NULL) AS total +''').getSingle(); + return count.read('total') > 0; + } + + @override + Future applyImportSnapshot({ + required LocalDataExportSnapshot snapshot, + required LocalBackupImportMode mode, + required DateTime importedAt, + }) async { + if (await hasOpenActiveWorkoutSession()) { + throw const LocalBackupException( + LocalBackupValidationError.activeWorkoutInProgress, + ); + } + var inserted = 0; + var updated = 0; + var ignored = 0; + var deleted = 0; + await database.transaction(() async { + if (mode == LocalBackupImportMode.replaceAll) { + deleted += await _softDeleteResourcesAbsentFromBackup( + snapshot, + importedAt, + ); + } + final force = mode == LocalBackupImportMode.replaceAll; + final mediaResult = await _applyBackupResources( + resources: snapshot.mediaAssets, + resourceType: SyncResourceType.mediaAsset, + tableName: 'media_assets', + importedAt: importedAt, + force: force, + ); + inserted += mediaResult.insertedCount; + updated += mediaResult.updatedCount; + ignored += mediaResult.ignoredOlderCount; + final exerciseResult = await _applyBackupResources( + resources: snapshot.exercises, + resourceType: SyncResourceType.exercise, + tableName: 'exercises', + importedAt: importedAt, + force: force, + ); + inserted += exerciseResult.insertedCount; + updated += exerciseResult.updatedCount; + ignored += exerciseResult.ignoredOlderCount; + final programResult = await _applyBackupResources( + resources: snapshot.programs, + resourceType: SyncResourceType.program, + tableName: 'programs', + importedAt: importedAt, + force: force, + ); + inserted += programResult.insertedCount; + updated += programResult.updatedCount; + ignored += programResult.ignoredOlderCount; + final templateResult = await _applyBackupResources( + resources: snapshot.workoutTemplates, + resourceType: SyncResourceType.workoutTemplate, + tableName: 'workout_templates', + importedAt: importedAt, + force: force, + ); + inserted += templateResult.insertedCount; + updated += templateResult.updatedCount; + ignored += templateResult.ignoredOlderCount; + final historyResult = await _applyBackupResources( + resources: snapshot.workoutHistories, + resourceType: SyncResourceType.workoutHistory, + tableName: 'workout_history', + importedAt: importedAt, + force: force, + ); + inserted += historyResult.insertedCount; + updated += historyResult.updatedCount; + ignored += historyResult.ignoredOlderCount; + }); + return LocalBackupImportResult( + insertedCount: inserted, + updatedCount: updated, + ignoredOlderCount: ignored, + deletedByReplaceCount: deleted, + missingMediaCount: 0, + ); + } + + Future _applyBackupResources({ + required List resources, + required SyncResourceType resourceType, + required String tableName, + required DateTime importedAt, + required bool force, + }) async { + var inserted = 0; + var updated = 0; + var ignored = 0; + for (final resource in resources) { + final local = await _localSyncRow(tableName, resource.id); + if (!force && + local != null && + !resource.updatedAt.isAfter(local.updatedAt)) { + ignored += 1; + continue; + } + final revision = (local?.localRevision ?? 0) + 1; + final payload = _payloadWithImportedMetadata( + resource.payload, + importedAt, + revision, + snapshotOriginDeviceId: resource.payload['originDeviceId'] as String?, + ); + await _saveBackupResource( + resourceType: resourceType, + resource: resource.copyWith(payload: payload), + importedAt: importedAt, + ); + if (local == null || local.deletedAt != null) { + inserted += 1; + } else { + updated += 1; + } + } + return LocalBackupImportResult( + insertedCount: inserted, + updatedCount: updated, + ignoredOlderCount: ignored, + deletedByReplaceCount: 0, + missingMediaCount: 0, + ); + } + + Future _saveBackupResource({ + required SyncResourceType resourceType, + required LocalBackupResource resource, + required DateTime importedAt, + }) async { + final remoteItem = _remoteItemFromBackupResource(resourceType, resource); + switch (resourceType) { + case SyncResourceType.mediaAsset: + await DriftMediaAssetRepository( + database, + ).save(_mediaAssetFromPayload(remoteItem)); + case SyncResourceType.exercise: + await DriftExerciseRepository( + database, + ).save(_exerciseFromPayload(remoteItem)); + case SyncResourceType.program: + await DriftProgramRepository( + database, + ).replaceExercises(_programFromPayload(remoteItem), importedAt); + case SyncResourceType.workoutTemplate: + await DriftWorkoutTemplateRepository(database).replaceComposition( + _workoutTemplateFromPayload(remoteItem), + importedAt, + ); + case SyncResourceType.workoutHistory: + await _replaceWorkoutHistory( + _workoutHistoryFromLocalBackupPayload(remoteItem), + importedAt, + ); + } + } + + Future _replaceWorkoutHistory( + domain.WorkoutHistory history, + DateTime deletedAt, + ) async { + await DriftWorkoutHistoryRepository(database).save(history); + final activeResultIds = history.results + .map((result) => result.metadata.id) + .toSet(); + final resultRows = + await (database.select(database.workoutHistorySetResults)..where( + (table) => + table.workoutHistoryId.equals(history.metadata.id) & + table.deletedAt.isNull() & + (activeResultIds.isEmpty + ? const Constant(true) + : table.id.isNotIn(activeResultIds)), + )) + .get(); + await _softDeleteWorkoutHistorySetResultRows( + database, + resultRows, + deletedAt, + ); + final activeStepResultIds = history.stepResults + .map((result) => result.metadata.id) + .toSet(); + final stepRows = + await (database.select(database.workoutHistoryStepResults)..where( + (table) => + table.workoutHistoryId.equals(history.metadata.id) & + table.deletedAt.isNull() & + (activeStepResultIds.isEmpty + ? const Constant(true) + : table.id.isNotIn(activeStepResultIds)), + )) + .get(); + await _softDeleteWorkoutHistoryStepResultRows( + database, + stepRows, + deletedAt, + ); + } + + Future _softDeleteResourcesAbsentFromBackup( + LocalDataExportSnapshot snapshot, + DateTime deletedAt, + ) async { + var deleted = 0; + deleted += await _softDeleteMainRowsAbsent( + tableName: 'media_assets', + entityType: 'MediaAsset', + keepIds: snapshot.mediaAssets.map((resource) => resource.id).toSet(), + deletedAt: deletedAt, + ); + deleted += await _softDeleteMainRowsAbsent( + tableName: 'exercises', + entityType: 'Exercise', + keepIds: snapshot.exercises.map((resource) => resource.id).toSet(), + deletedAt: deletedAt, + ); + deleted += await _softDeleteMainRowsAbsent( + tableName: 'programs', + entityType: 'Program', + keepIds: snapshot.programs.map((resource) => resource.id).toSet(), + deletedAt: deletedAt, + ); + deleted += await _softDeleteMainRowsAbsent( + tableName: 'workout_templates', + entityType: 'WorkoutTemplate', + keepIds: snapshot.workoutTemplates.map((resource) => resource.id).toSet(), + deletedAt: deletedAt, + ); + deleted += await _softDeleteMainRowsAbsent( + tableName: 'workout_history', + entityType: 'WorkoutHistory', + keepIds: snapshot.workoutHistories.map((resource) => resource.id).toSet(), + deletedAt: deletedAt, + ); + await _softDeleteAllActiveSessionRows(deletedAt); + return deleted; + } + + Future _softDeleteMainRowsAbsent({ + required String tableName, + required String entityType, + required Set keepIds, + required DateTime deletedAt, + }) async { + final where = keepIds.isEmpty + ? 'deleted_at IS NULL' + : 'deleted_at IS NULL AND id NOT IN (${List.filled(keepIds.length, '?').join(', ')})'; + final rows = await database + .customSelect( + 'SELECT id, local_revision, origin_device_id FROM $tableName WHERE $where', + variables: [for (final id in keepIds) Variable(id)], + ) + .get(); + for (final row in rows) { + final id = row.read('id'); + final revision = row.read('local_revision') + 1; + await database.customUpdate( + 'UPDATE $tableName SET deleted_at = ?, updated_at = ?, ' + 'local_revision = ?, sync_state = ? WHERE id = ?', + variables: [ + Variable(deletedAt.toUtc()), + Variable(deletedAt.toUtc()), + Variable(revision), + const Variable('deleted'), + Variable(id), + ], + ); + await _writeChangeLog( + database: database, + entityType: entityType, + entityId: id, + operation: 'softDelete', + localRevision: revision, + originDeviceId: row.read('origin_device_id'), + createdAt: deletedAt, + ); + } + return rows.length; + } + + Future _softDeleteAllActiveSessionRows(DateTime deletedAt) async { + await database.customUpdate( + 'UPDATE active_workout_sessions SET deleted_at = ?, updated_at = ? ' + 'WHERE deleted_at IS NULL', + variables: [ + Variable(deletedAt.toUtc()), + Variable(deletedAt.toUtc()), + ], + updates: {database.activeWorkoutSessions}, + ); + } + + Future<_BackupLocalRow?> _localSyncRow(String tableName, String id) async { + final row = await database + .customSelect( + 'SELECT updated_at, deleted_at, local_revision FROM $tableName ' + 'WHERE id = ? LIMIT 1', + variables: [Variable(id)], + ) + .getSingleOrNull(); + if (row == null) { + return null; + } + return _BackupLocalRow( + updatedAt: _dateTimeFromData(row.data, 'updated_at'), + deletedAt: _dateTimeOrNullFromData(row.data, 'deleted_at'), + localRevision: row.read('local_revision'), + ); + } +} + final class DriftExercisePerformanceReferenceRepository implements ExercisePerformanceReferenceRepository { const DriftExercisePerformanceReferenceRepository(this.database); @@ -4051,6 +4435,18 @@ final class _LocalSyncSnapshot { final Map payload; } +final class _BackupLocalRow { + const _BackupLocalRow({ + required this.updatedAt, + required this.deletedAt, + required this.localRevision, + }); + + final DateTime updatedAt; + final DateTime? deletedAt; + final int localRevision; +} + Map _metadataPayload(domain.EntityMetadata metadata) => { 'id': metadata.id, 'createdAt': metadata.createdAt.toUtc().toIso8601String(), @@ -4166,6 +4562,166 @@ Map _workoutHistoryPayload(domain.WorkoutHistory history) => { 'historySnapshotJson': history.historySnapshotJson, }; +Map _localWorkoutHistoryPayload( + domain.WorkoutHistory history, +) => { + ..._workoutHistoryPayload(history), + 'results': history.results.map(_workoutHistorySetResultPayload).toList(), + 'stepResults': history.stepResults + .map(_workoutHistoryStepResultPayload) + .toList(), +}; + +Map _workoutHistorySetResultPayload( + domain.WorkoutHistorySetResult result, +) => { + 'metadata': _metadataPayload(result.metadata), + 'id': result.metadata.id, + 'workoutHistoryId': result.workoutHistoryId, + 'programSnapshotId': result.programSnapshotId, + 'exerciseSnapshotId': result.exerciseSnapshotId, + 'programIndex': result.programIndex, + 'exerciseIndex': result.exerciseIndex, + 'setIndex': result.setIndex, + 'programNameSnapshot': result.programNameSnapshot, + 'exerciseNameSnapshot': result.exerciseNameSnapshot, + 'timeEnabledSnapshot': result.timeEnabledSnapshot, + 'repsEnabledSnapshot': result.repsEnabledSnapshot, + 'scoreEnabledSnapshot': result.scoreEnabledSnapshot, + 'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name, + 'targetTimeSecondsSnapshot': result.targetTimeSecondsSnapshot, + 'targetRepsSnapshot': result.targetRepsSnapshot, + 'targetScoreSnapshot': result.targetScoreSnapshot, + 'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot, + 'actualTimeMs': result.actualTimeMs, + 'actualReps': result.actualReps, + 'actualScore': result.actualScore, + 'actualScoreTimeMs': result.actualScoreTimeMs, + 'scoreLabelSnapshot': result.scoreLabelSnapshot, + 'scoreUnitSnapshot': result.scoreUnitSnapshot, + 'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot, + 'startedAt': result.startedAt?.toUtc().toIso8601String(), + 'completedAt': result.completedAt?.toUtc().toIso8601String(), + 'status': result.status.name, +}; + +Map _workoutHistoryStepResultPayload( + domain.WorkoutHistoryStepResult result, +) => { + 'metadata': _metadataPayload(result.metadata), + 'id': result.metadata.id, + 'workoutHistoryId': result.workoutHistoryId, + 'programSnapshotId': result.programSnapshotId, + 'exerciseSnapshotId': result.exerciseSnapshotId, + 'programIndex': result.programIndex, + 'exerciseIndex': result.exerciseIndex, + 'setIndex': result.setIndex, + 'passageIndex': result.passageIndex, + 'stepIndex': result.stepIndex, + 'stepSnapshotId': result.stepSnapshotId, + 'stepNameSnapshot': result.stepNameSnapshot, + 'stepTypeSnapshot': result.stepTypeSnapshot.name, + 'targetValueSnapshot': result.targetValueSnapshot, + 'hasScoreSnapshot': result.hasScoreSnapshot, + 'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name, + 'scoreLabelSnapshot': result.scoreLabelSnapshot, + 'scoreUnitSnapshot': result.scoreUnitSnapshot, + 'targetScoreSnapshot': result.targetScoreSnapshot, + 'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot, + 'status': result.status.name, + 'startedAt': result.startedAt?.toUtc().toIso8601String(), + 'completedAt': result.completedAt?.toUtc().toIso8601String(), + 'actualTimeMs': result.actualTimeMs, + 'actualReps': result.actualReps, + 'actualScore': result.actualScore, + 'actualScoreTimeMs': result.actualScoreTimeMs, + 'note': result.note, + 'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot, +}; + +LocalBackupResource _backupResource( + domain.EntityMetadata metadata, + Map payload, +) { + return LocalBackupResource( + id: metadata.id, + updatedAt: metadata.updatedAt.toUtc(), + payload: payload, + ); +} + +RemoteSyncedItem _remoteItemFromBackupResource( + SyncResourceType resourceType, + LocalBackupResource resource, +) { + final metadata = resource.payload['metadata']; + final metadataMap = metadata is Map + ? Map.from(metadata) + : const {}; + return RemoteSyncedItem( + resourceType: resourceType, + clientId: resource.id, + serverId: '', + schemaVersion: metadataMap['schemaVersion'] as int? ?? 1, + clientUpdatedAt: resource.updatedAt.toUtc(), + serverUpdatedAt: resource.updatedAt.toUtc(), + deletedAt: _dateTimeFromPayload(metadataMap['deletedAt']), + payload: resource.payload, + ); +} + +Map _payloadWithImportedMetadata( + Map payload, + DateTime importedAt, + int localRevision, { + String? snapshotOriginDeviceId, +}) { + final metadata = payload['metadata']; + final metadataMap = metadata is Map + ? Map.from(metadata) + : {}; + return { + ...payload, + 'metadata': { + ...metadataMap, + 'updatedAt': importedAt.toUtc().toIso8601String(), + 'deletedAt': null, + 'syncState': domain.SyncState.dirty.name, + 'localRevision': localRevision, + 'originDeviceId': + metadataMap['originDeviceId'] as String? ?? + snapshotOriginDeviceId ?? + 'local-device', + }, + }; +} + +domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload( + RemoteSyncedItem item, +) { + final payload = item.payload; + final metadata = _metadataFromPayload(item); + return domain.WorkoutHistory( + metadata: metadata, + sourceWorkoutTemplateId: payload['sourceWorkoutTemplateId'] as String?, + sourceActiveWorkoutSessionId: + payload['sourceActiveWorkoutSessionId'] as String?, + nameSnapshot: _stringFromPayload(payload, 'nameSnapshot', item.clientId), + startedAt: + _dateTimeFromPayload(payload['startedAt']) ?? item.clientUpdatedAt, + endedAt: _dateTimeFromPayload(payload['endedAt']) ?? item.clientUpdatedAt, + totalActiveMs: payload['totalActiveMs'] as int? ?? 0, + completed: payload['completed'] as bool? ?? false, + historySnapshotJson: + payload['historySnapshotJson'] as String? ?? '{"programs":[]}', + results: _workoutHistorySetResultsFromPayload(payload['results'], metadata), + stepResults: _workoutHistoryStepResultsFromPayload( + payload['stepResults'], + metadata, + ), + ); +} + domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) { final payload = item.payload; return domain.Exercise( @@ -4257,7 +4813,9 @@ domain.EntityMetadata _metadataFromPayload(RemoteSyncedItem item) { updatedAt: item.clientUpdatedAt, deletedAt: item.deletedAt, schemaVersion: item.schemaVersion, - syncState: item.deletedAt == null + syncState: map?['syncState'] is String + ? _syncStateFromDb(map!['syncState'] as String) + : item.deletedAt == null ? domain.SyncState.synced : domain.SyncState.deleted, localRevision: map?['localRevision'] as int? ?? 0, @@ -4280,7 +4838,7 @@ List _programExercisesFromPayload( return domain.ProgramExercise( metadata: _childMetadataFromPayload(map, id, parentMetadata), programId: parentMetadata.id, - sourceExerciseId: null, + sourceExerciseId: map['sourceExerciseId'] as String?, position: map['position'] as int? ?? 0, exerciseNameSnapshot: _stringFromPayload( map, @@ -4342,7 +4900,7 @@ List _workoutTemplateProgramsFromPayload( return domain.WorkoutTemplateProgram( metadata: _childMetadataFromPayload(map, id, parentMetadata), workoutTemplateId: parentMetadata.id, - sourceProgramId: null, + sourceProgramId: map['sourceProgramId'] as String?, position: map['position'] as int? ?? 0, programNameSnapshot: _stringFromPayload( map, @@ -4395,6 +4953,115 @@ _workoutTemplateOverridesFromPayload( .toList(growable: false); } +List _workoutHistorySetResultsFromPayload( + Object? value, + domain.EntityMetadata parentMetadata, +) { + if (value is! List) { + return const []; + } + return value + .whereType() + .map((entry) { + final map = Map.from(entry); + final id = _stringFromPayload(map, 'id', 'history-set-result'); + return domain.WorkoutHistorySetResult( + metadata: _childMetadataFromPayload(map, id, parentMetadata), + workoutHistoryId: parentMetadata.id, + programSnapshotId: _stringFromPayload(map, 'programSnapshotId', ''), + exerciseSnapshotId: _stringFromPayload(map, 'exerciseSnapshotId', ''), + programIndex: map['programIndex'] as int? ?? 0, + exerciseIndex: map['exerciseIndex'] as int? ?? 0, + setIndex: map['setIndex'] as int? ?? 0, + programNameSnapshot: _stringFromPayload( + map, + 'programNameSnapshot', + '', + ), + exerciseNameSnapshot: _stringFromPayload( + map, + 'exerciseNameSnapshot', + '', + ), + timeEnabledSnapshot: map['timeEnabledSnapshot'] == true, + repsEnabledSnapshot: map['repsEnabledSnapshot'] == true, + scoreEnabledSnapshot: map['scoreEnabledSnapshot'] == true, + scoreInputModeSnapshot: _scoreInputModeFromDb( + map['scoreInputModeSnapshot'] as String? ?? 'manual', + ), + targetTimeSecondsSnapshot: map['targetTimeSecondsSnapshot'] as int?, + targetRepsSnapshot: map['targetRepsSnapshot'] as int?, + targetScoreSnapshot: (map['targetScoreSnapshot'] as num?)?.toDouble(), + targetScoreTimeMsSnapshot: map['targetScoreTimeMsSnapshot'] as int?, + actualTimeMs: map['actualTimeMs'] as int?, + actualReps: map['actualReps'] as int?, + actualScore: (map['actualScore'] as num?)?.toDouble(), + actualScoreTimeMs: map['actualScoreTimeMs'] as int?, + scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?, + scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?, + sourceExerciseIdSnapshot: map['sourceExerciseIdSnapshot'] as String?, + startedAt: _dateTimeFromPayload(map['startedAt']), + completedAt: _dateTimeFromPayload(map['completedAt']), + status: _setResultStatusFromDb( + map['status'] as String? ?? 'completed', + ), + ); + }) + .toList(growable: false); +} + +List _workoutHistoryStepResultsFromPayload( + Object? value, + domain.EntityMetadata parentMetadata, +) { + if (value is! List) { + return const []; + } + return value + .whereType() + .map((entry) { + final map = Map.from(entry); + final id = _stringFromPayload(map, 'id', 'history-step-result'); + return domain.WorkoutHistoryStepResult( + metadata: _childMetadataFromPayload(map, id, parentMetadata), + workoutHistoryId: parentMetadata.id, + programSnapshotId: _stringFromPayload(map, 'programSnapshotId', ''), + exerciseSnapshotId: _stringFromPayload(map, 'exerciseSnapshotId', ''), + programIndex: map['programIndex'] as int? ?? 0, + exerciseIndex: map['exerciseIndex'] as int? ?? 0, + setIndex: map['setIndex'] as int? ?? 0, + passageIndex: map['passageIndex'] as int? ?? 0, + stepIndex: map['stepIndex'] as int? ?? 0, + stepSnapshotId: _stringFromPayload(map, 'stepSnapshotId', ''), + stepNameSnapshot: _stringFromPayload(map, 'stepNameSnapshot', ''), + stepTypeSnapshot: _exerciseStepTypeFromDb( + map['stepTypeSnapshot'] as String? ?? 'work', + ), + targetValueSnapshot: map['targetValueSnapshot'] as int? ?? 0, + hasScoreSnapshot: map['hasScoreSnapshot'] == true, + scoreInputModeSnapshot: map['scoreInputModeSnapshot'] == null + ? null + : _scoreInputModeFromDb(map['scoreInputModeSnapshot'] as String), + scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?, + scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?, + targetScoreSnapshot: (map['targetScoreSnapshot'] as num?)?.toDouble(), + targetScoreTimeMsSnapshot: map['targetScoreTimeMsSnapshot'] as int?, + status: _setResultStatusFromDb( + map['status'] as String? ?? 'completed', + ), + startedAt: _dateTimeFromPayload(map['startedAt']), + completedAt: _dateTimeFromPayload(map['completedAt']), + actualTimeMs: map['actualTimeMs'] as int?, + actualReps: map['actualReps'] as int?, + actualScore: (map['actualScore'] as num?)?.toDouble(), + actualScoreTimeMs: map['actualScoreTimeMs'] as int?, + note: map['note'] as String?, + sourceExerciseIdSnapshot: map['sourceExerciseIdSnapshot'] as String?, + ); + }) + .toList(growable: false); +} + domain.EntityMetadata _childMetadataFromPayload( Map payload, String id, diff --git a/lib/infrastructure/local/local_media_storage.dart b/lib/infrastructure/local/local_media_storage.dart index f515175..8b48562 100644 --- a/lib/infrastructure/local/local_media_storage.dart +++ b/lib/infrastructure/local/local_media_storage.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -7,7 +8,8 @@ import 'package:path_provider/path_provider.dart'; import '../../application/application.dart'; import '../../domain/domain.dart'; -final class PathProviderLocalMediaStorage implements LocalMediaStorage { +final class PathProviderLocalMediaStorage + implements LocalMediaStorage, LocalBackupMediaStore { const PathProviderLocalMediaStorage(); static const _mediaDirectoryName = 'gametime_media'; @@ -77,6 +79,64 @@ final class PathProviderLocalMediaStorage implements LocalMediaStorage { } } + @override + Future> readEmbeddableFiles( + List mediaAssets, + ) async { + final files = []; + for (final resource in mediaAssets) { + final localUri = resource.payload['localUri']; + if (localUri is! String || localUri.isEmpty) { + continue; + } + final file = _fileFromLocalUri(localUri); + if (!await file.exists()) { + continue; + } + final bytes = await file.readAsBytes(); + files.add( + EmbeddedBackupMediaFile( + mediaAssetId: resource.id, + role: 'original', + fileName: p.basename(file.path), + mimeType: resource.payload['mimeType'] as String?, + sizeBytes: bytes.length, + base64: base64Encode(bytes), + ), + ); + } + return files; + } + + @override + Future> restoreEmbeddedFiles( + List files, + ) async { + final restored = {}; + final directory = await _restoredBackupDirectory(); + await directory.create(recursive: true); + for (final file in files) { + final bytes = switch (_decodeBase64OrNull(file.base64)) { + final decoded? => decoded, + null => null, + }; + if (bytes == null) { + continue; + } + final extension = p.extension(file.fileName).toLowerCase(); + final target = File( + p.join(directory.path, '${file.mediaAssetId}$extension'), + ); + await target.writeAsBytes(bytes, flush: true); + restored[file.mediaAssetId] = RestoredMediaFile( + mediaAssetId: file.mediaAssetId, + localUri: target.uri.toString(), + sizeBytes: bytes.length, + ); + } + return restored; + } + Future _kindDirectory(MediaKind kind) async { final root = await _rootDirectory(); final child = switch (kind) { @@ -90,6 +150,27 @@ final class PathProviderLocalMediaStorage implements LocalMediaStorage { final documents = await getApplicationDocumentsDirectory(); return Directory(p.join(documents.path, _mediaDirectoryName)); } + + Future _restoredBackupDirectory() async { + final root = await _rootDirectory(); + return Directory(p.join(root.path, 'restored')); + } +} + +File _fileFromLocalUri(String localUri) { + final uri = Uri.tryParse(localUri); + if (uri != null && uri.scheme == 'file') { + return File.fromUri(uri); + } + return File(localUri); +} + +Uint8List? _decodeBase64OrNull(String value) { + try { + return base64Decode(value); + } on FormatException { + return null; + } } final class _ImageDimensions { diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 0799667..282a6f5 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -243,6 +243,16 @@ final class _FakeBootstrap implements AppDependencies { ), exercisePerformanceReferenceUseCase = ExercisePerformanceReferenceUseCase( repository: _FakeExercisePerformanceReferenceRepository(), + ), + dataExportUseCase = DataExportUseCase( + repository: _FakeLocalDataBackupRepository(), + mediaStore: _FakeLocalBackupMediaStore(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ), + dataImportUseCase = DataImportUseCase( + repository: _FakeLocalDataBackupRepository(), + mediaStore: _FakeLocalBackupMediaStore(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ); @override @@ -283,6 +293,12 @@ final class _FakeBootstrap implements AppDependencies { @override final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase; + + @override + final DataExportUseCase dataExportUseCase; + + @override + final DataImportUseCase dataImportUseCase; } String _sessionSnapshot() { @@ -682,6 +698,62 @@ final class _FakeMediaStorage implements LocalMediaStorage { Future> listManagedLocalUris() async => const {}; } +final class _FakeLocalDataBackupRepository + implements LocalDataBackupRepository { + @override + Future applyImportSnapshot({ + required LocalDataExportSnapshot snapshot, + required LocalBackupImportMode mode, + required DateTime importedAt, + }) async { + return const LocalBackupImportResult( + insertedCount: 0, + updatedCount: 0, + ignoredOlderCount: 0, + deletedByReplaceCount: 0, + missingMediaCount: 0, + ); + } + + @override + Future hasAnyUserData() async => false; + + @override + Future hasOpenActiveWorkoutSession() async => false; + + @override + Future readExportSnapshot( + DateTime exportedAt, + ) async { + return LocalDataExportSnapshot( + exportedAt: exportedAt, + appSchemaVersion: 1, + originDeviceId: 'device-1', + mediaAssets: const [], + exercises: const [], + programs: const [], + workoutTemplates: const [], + workoutHistories: const [], + ); + } +} + +final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore { + @override + Future> readEmbeddableFiles( + List mediaAssets, + ) async { + return const []; + } + + @override + Future> restoreEmbeddedFiles( + List files, + ) async { + return const {}; + } +} + final class _FakeProgramRepository implements ProgramRepository { @override Future findById(String id) async => null;