feat: add local backup export import core

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:23:30 +02:00
parent b256595e08
commit c3d5273869
6 changed files with 1380 additions and 4 deletions

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:typed_data';
import '../domain/domain.dart';
import 'ports.dart';
@ -49,6 +50,175 @@ List<TagUsage> tagSuggestionsFor<T>(
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 = <String, Object?>{
'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<String, Object?>.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<String, Object?>.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<LocalBackupDocument> 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<LocalBackupPreview> preview(Uint8List bytes) async {
return codec.preview(bytes);
}
Future<LocalBackupImportResult> 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<String> existingNames) {
copyNumber += 1;
}
}
Map<String, Object?> _countsToJson(LocalBackupCounts counts) => {
'exercises': counts.exercises,
'programs': counts.programs,
'workoutTemplates': counts.workoutTemplates,
'workoutHistories': counts.workoutHistories,
'mediaAssets': counts.mediaAssets,
'embeddedMediaFiles': counts.embeddedMediaFiles,
};
Map<String, Object?> _mediaFileToJson(EmbeddedBackupMediaFile file) => {
'mediaAssetId': file.mediaAssetId,
'role': file.role,
'fileName': file.fileName,
'mimeType': file.mimeType,
'sizeBytes': file.sizeBytes,
'base64': file.base64,
};
List<LocalBackupResource> _resourcesFromBackup(
Map<String, Object?> 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<String, Object?>.from(item);
return LocalBackupResource(
id: _stringFromBackup(payload, 'id'),
updatedAt: _updatedAtFromBackupPayload(payload),
payload: payload,
);
})
.toList(growable: false);
}
List<EmbeddedBackupMediaFile> _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<String, Object?>.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<String, Object?> payload, String key) {
final value = payload[key];
if (value is String && value.isNotEmpty) {
return value;
}
throw const LocalBackupException(LocalBackupValidationError.corrupted);
}
DateTime _updatedAtFromBackupPayload(Map<String, Object?> payload) {
final metadata = payload['metadata'];
if (metadata is! Map) {
throw const LocalBackupException(LocalBackupValidationError.corrupted);
}
return _dateTimeFromBackup(Map<String, Object?>.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<LocalBackupResource> resources) {
final names = <String>{};
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<String, RestoredMediaFile> 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';
}