diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index 57d2cb8..e096516 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -34,6 +34,7 @@ part 'storage_service_export.dart'; part 'storage_service_import.dart'; part 'storage_service_pc_link.dart'; part 'storage_service_runs.dart'; +part 'storage_service_runs_transfer.dart'; part 'storage_service_sandbox.dart'; part 'storage_service_schema.dart'; part 'storage_service_sessions.dart'; diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart index 8ad8055..d0d098a 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart @@ -22,16 +22,19 @@ extension StorageServiceBackup on StorageService { final historyRows = await _db.query('workout_history'); final settingsRows = await _db.query('settings'); final eventRows = await _db.query('progression_events'); + final runRows = await _runsForTransfer(appOnly: true); - if (historyRows.isEmpty) { + if (historyRows.isEmpty && runRows.isEmpty) { final existing = await BackupService.instance.readBackup(); final existingHistory = (existing?['workout_history'] as List?) ?? const []; - if (existingHistory.isNotEmpty) { + final existingRuns = (existing?['runs'] as List?) ?? const []; + if (existingHistory.isNotEmpty || existingRuns.isNotEmpty) { debugPrint( 'WorkoutApp: REFUSING to overwrite the backup — this DB has no ' - 'workout history but the backup holds ${existingHistory.length} ' - 'session(s). A restore almost certainly failed; keeping the backup ' + 'workout history or runs but the backup holds ' + '${existingHistory.length} session(s) and ${existingRuns.length} ' + 'run(s). A restore almost certainly failed; keeping the backup ' 'so the data stays recoverable.', ); return; @@ -43,6 +46,7 @@ extension StorageServiceBackup on StorageService { 'workout_history': historyRows, 'settings': settingsRows, 'progression_events': eventRows, + 'runs': runRows, }); } @@ -173,6 +177,8 @@ extension StorageServiceBackup on StorageService { conflictAlgorithm: ConflictAlgorithm.replace, ); } + // Absent from older backups; null merges as nothing. + await _mergeRuns(txn, backup['runs']); }); } } diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_export.dart b/stronglift_replacement/workout_app/lib/services/storage_service_export.dart index eb5c93d..d41fac9 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_export.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_export.dart @@ -7,8 +7,9 @@ part of 'storage_service.dart'; const kExportFormat = 'workout_app-export'; /// The format written now. Version 2 adds `updated_at` to `exercise_state` and -/// `settings` rows plus `manual_workouts`; version 1 files still import. -const kExportVersion = 2; +/// `settings` rows plus `manual_workouts`; version 3 adds `runs` (with their +/// points); version 1 and 2 files still import. +const kExportVersion = 3; const _exportTables = [ 'workout_history', @@ -47,6 +48,7 @@ extension StorageServiceExport on StorageService { for (final row in settings) if (!_isPerInstallSetting(row['key']! as String)) row, ], + 'runs': await _runsForTransfer(appOnly: false), 'manual_workouts': manual == null ? null : [ diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_import.dart b/stronglift_replacement/workout_app/lib/services/storage_service_import.dart index 230f0b1..ab1119b 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_import.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_import.dart @@ -24,7 +24,7 @@ extension StorageServiceImport on StorageService { /// [ImportReport.manualSkipped] says so. Idempotent. Future importDataReport(Map data) async { if (!_exportTables.any((t) => data[t] is List)) { - if (data['manual_workouts'] is! List) { + if (data['manual_workouts'] is! List && data['runs'] is! List) { throw const FormatException('not a workout_app export'); } } @@ -36,6 +36,7 @@ extension StorageServiceImport on StorageService { for (final table in _exportTables) { rows += await _mergeTable(txn, table, data[table], fileWins, stamped); } + rows += await _mergeRuns(txn, data['runs']); }); if (rows > 0) unawaited(_backupNow()); final manual = await _importManual(data['manual_workouts']); diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_runs.dart b/stronglift_replacement/workout_app/lib/services/storage_service_runs.dart index b6fa8e9..c30f13b 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_runs.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_runs.dart @@ -61,7 +61,7 @@ extension StorageServiceRuns on StorageService { 'an imported run must be RunUploadState.never', ); } - return await _db.transaction((txn) async { + final added = await _db.transaction((txn) async { final clash = await txn.query( 'runs', columns: ['id'], @@ -95,6 +95,9 @@ extension StorageServiceRuns on StorageService { await batch.commit(noResult: true); return true; }); + // A recorded run exists nowhere else yet; the RunnerUp history does (PC). + if (added && track.source == RunSource.app) unawaited(_backupNow()); + return added; } /// Whether a run with this TCX hash is stored. diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart b/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart new file mode 100644 index 0000000..c65373c --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart @@ -0,0 +1,136 @@ +// Runs in backup.json and the user export: the `runs` / `run_points` half of +// both, written and merged by one code path. +// +// A `part` of StorageService (it needs `_db`). The generic table merge cannot +// be reused: it strips every `id` column and falls back to a progression +// event key, which would store runs with NULL ids. +part of 'storage_service.dart'; + +/// Runs as portable rows. +extension StorageServiceRunsTransfer on StorageService { + /// Every run as a `runs` row plus `points`: ONE compact JSON string of + /// `[time_ms, lat, lon, alt, distance_m]` rows. A string, not nested lists, + /// so pretty-printing the document does not put every fix on its own line + /// (a year of runs would otherwise be tens of MB, re-encoded on every + /// backup). + /// + /// [appOnly] leaves the RunnerUp history out: it is reproducible from the + /// PC, so the automatic backup carries only what exists nowhere else. + Future>> _runsForTransfer({ + required bool appOnly, + }) async { + final rows = await _db.query( + 'runs', + where: appOnly ? "source = 'app'" : null, + orderBy: 'started_at, id', + ); + return [ + for (final row in rows) + {...row, 'points': await _encodePoints(row['id']! as String)}, + ]; + } + + Future _encodePoints(String runId) async { + final points = await _db.query( + 'run_points', + where: 'run_id = ?', + whereArgs: [runId], + orderBy: 'seq', + ); + return jsonEncode([ + for (final p in points) + [p['time_ms'], p['lat'], p['lon'], p['alt'], p['distance_m']], + ]); + } + + /// Merges transferred [raw] runs into the database and returns how many + /// were new. Idempotent: a run whose id OR TCX hash is already stored is + /// left alone (and so are its points); a malformed entry is counted and + /// skipped, never fatal. An imported run is stored as `never` whatever the + /// file claims, since the table's CHECK would reject anything else. + Future _mergeRuns(Transaction txn, Object? raw) async { + if (raw is! List) { + if (raw != null) _warnSkipped('runs', 1); + return 0; + } + final known = await txn.query('runs', columns: ['id', 'tcx_sha256']); + final ids = {for (final r in known) r['id']! as String}; + final shas = {for (final r in known) r['tcx_sha256']! as String}; + var written = 0; + var malformed = 0; + for (final entry in raw) { + final parsed = entry is Map ? _parseRun(entry) : null; + if (parsed == null) { + malformed++; + continue; + } + final (row, points) = parsed; + final id = row['id']! as String; + final sha = row['tcx_sha256']! as String; + if (ids.contains(id) || shas.contains(sha)) continue; + ids.add(id); + shas.add(sha); + await txn.insert('runs', row); + final batch = txn.batch(); + for (final (seq, p) in points.indexed) { + batch.insert('run_points', { + 'run_id': row['id'], + 'seq': seq, + 'time_ms': p[0], + 'lat': p[1], + 'lon': p[2], + 'alt': p[3], + 'distance_m': p[4], + }); + } + await batch.commit(noResult: true); + written++; + } + if (malformed > 0) _warnSkipped('runs', malformed); + return written; + } + + /// The `runs` row and decoded points of [e], or null when it is malformed. + (Map, List>)? _parseRun( + Map e, + ) { + try { + final source = RunSource.fromStorageKey(e['source'] as String); + final state = source == RunSource.runnerupImport + ? RunUploadState.never + : RunUploadState.fromStorageKey(e['upload_state'] as String); + final points = [ + for (final p in jsonDecode(e['points'] as String) as List) + [ + (p as List)[0] as int, + (p[1] as num).toDouble(), + (p[2] as num).toDouble(), + (p[3] as num?)?.toDouble(), + (p[4] as num).toDouble(), + ], + ]; + final sport = RunSport.fromTcxName(e['sport'] as String)!.tcxName; + final id = e['id'] as String; + final sha = e['tcx_sha256'] as String; + if (id.isEmpty || sha.isEmpty) return null; + return ( + { + 'id': id, + 'sport': sport, + 'started_at': e['started_at'] as int, + 'total_seconds': e['total_seconds'] as int, + 'distance_m': (e['distance_m'] as num).toDouble(), + 'source': source.storageKey, + 'upload_state': state.storageKey, + 'tcx_filename': e['tcx_filename'] as String, + 'tcx_sha256': sha, + }, + points, + ); + } on Object { + // Wrong shape, unknown enum key, or a points string that is not JSON: + // the caller counts it and logs the total via _warnSkipped. + return null; + } + } +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart index 3005eeb..8dee8ed 100644 --- a/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart +++ b/stronglift_replacement/workout_app/test/services/storage_service_import_merge_test.dart @@ -50,9 +50,10 @@ void main() { return await _row('exercise_state', _name); } - test('export is version 2 and rows carry updated_at', () async { + test('export is the current version (3) and rows carry updated_at', () async { final data = await _svc.exportData(); - expect(data['version'], 2); + expect(data['version'], kExportVersion); + expect(kExportVersion, 3); expect( (await _row('exercise_state', _name)).containsKey('updated_at'), true, diff --git a/stronglift_replacement/workout_app/test/services/storage_service_runs_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_runs_test.dart index 3bafb64..74dd345 100644 --- a/stronglift_replacement/workout_app/test/services/storage_service_runs_test.dart +++ b/stronglift_replacement/workout_app/test/services/storage_service_runs_test.dart @@ -169,6 +169,9 @@ void main() { await StorageService.init(); final t = _track(); await _insert(t); + // A recorded run triggers an unawaited backup; let it finish before the + // tables are dropped under it. + await Future.delayed(const Duration(milliseconds: 100)); StorageService.resetForTesting(); final raw = await databaseFactory.openDatabase(path); diff --git a/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart new file mode 100644 index 0000000..c771a42 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart @@ -0,0 +1,237 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/tcx_codec.dart'; + +import '_runnerup_fake_dufs.dart'; + +StorageService get _svc => StorageService.instance; + +/// The three fixtures, decoded: two app-made, one RunnerUp export. +List<(String, String, RunTrack)> _tracks() => [ + for (final e in fixtureTcx().entries) + ( + e.key, + utf8.decode(e.value), + decodeTcx(utf8.decode(e.value), fileName: e.key), + ), +]; + +Future _insertAll() async { + for (final (name, xml, track) in _tracks()) { + final imported = track.source == RunSource.runnerupImport; + await _svc.insertRun( + track, + tcxFilename: name, + tcxSha256: tcxSha256(xml), + uploadState: imported ? RunUploadState.never : RunUploadState.pending, + ); + } +} + +Future _freshInstall() async { + StorageService.resetForTesting(); + await StorageService.init(); +} + +Future> _backupOnceItHolds(int runs) async { + for (var i = 0; i < 150; i++) { + final b = await BackupService.instance.readBackup(); + if (b != null && ((b['runs'] as List?)?.length ?? 0) >= runs) return b; + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('backup.json never held $runs run(s)'); +} + +void main() { + late Directory tmp; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tmp = Directory.systemTemp.createTempSync('runs_transfer'); + BackupService.baseDir = tmp.path; + await _freshInstall(); + }); + + tearDown(() { + BackupService.baseDir = kBackupDir; + tmp.deleteSync(recursive: true); + }); + + group('backup.json', () { + test( + 'wipe the DB, restore: recorded runs and their points return', + () async { + await _insertAll(); + final backup = await _backupOnceItHolds(2); + final saved = { + for (final r in await _svc.listRuns()) + if (r.track.source == RunSource.app) + r.track.id: await _svc.getRun(r.track.id), + }; + expect(saved, hasLength(2)); + // RunnerUp history is reproducible from the PC: not duplicated here. + expect( + (backup['runs'] as List).map((r) => (r as Map)['source']).toSet(), + {'app'}, + ); + final text = const JsonEncoder.withIndent(' ').convert(backup); + expect( + text.split('\n').length, + lessThan(200), + reason: 'points must stay one string per run, not one line per fix', + ); + + await _freshInstall(); // the reinstall + expect(await _svc.listRuns(), isEmpty); + await _svc.restoreFromBackupIfNeeded(); + + final restored = await _svc.listRuns(); + expect(restored.map((r) => r.track.id).toSet(), saved.keys.toSet()); + for (final run in saved.values) { + final back = (await _svc.getRun(run!.track.id))!; + expect(back.track.points, run.track.points); + expect(back.track.points, isNotEmpty); + expect(back.uploadState, run.uploadState); + expect(back.tcxSha256, run.tcxSha256); + expect(back.track.totalSeconds, run.track.totalSeconds); + expect(back.track.distanceM, run.track.distanceM); + } + }, + ); + + test('restoring twice stores nothing twice', () async { + await _insertAll(); + await _backupOnceItHolds(2); + await _freshInstall(); + await _svc.restoreFromBackupIfNeeded(); + final once = await _svc.listRuns(); + + await _svc.restoreFromBackupIfNeeded(); + + expect(await _svc.listRuns(), hasLength(once.length)); + }); + + test('a run-less fresh DB cannot overwrite a runs-only backup', () async { + await _insertAll(); + final before = await _backupOnceItHolds(2); + await _freshInstall(); + + await _svc.setLastWorkoutType('A'); // triggers _backupNow + await Future.delayed(const Duration(milliseconds: 100)); + + final after = await BackupService.instance.readBackup(); + expect( + after!['runs'] as List, + hasLength((before['runs'] as List).length), + ); + }); + }); + + group('user export / merge-import', () { + test( + 'round trip keeps every run and point; a re-import adds nothing', + () async { + await _insertAll(); + final originals = { + for (final r in await _svc.listRuns()) + r.track.id: (await _svc.getRun(r.track.id))!, + }; + final data = + jsonDecode(jsonEncode(await _svc.exportData())) + as Map; + expect(data['version'], kExportVersion); + expect(data['runs'], hasLength(3)); + + await _freshInstall(); + final report = await _svc.importDataReport(data); + + expect(report.rows, greaterThanOrEqualTo(3)); + for (final run in originals.values) { + final back = (await _svc.getRun(run.track.id))!; + expect(back.track.points, run.track.points); + expect(back.track.source, run.track.source); + expect(back.uploadState, run.uploadState); + } + final again = await _svc.importDataReport(data); + expect(again.rows, 0); + expect(await _svc.listRuns(), hasLength(3)); + }, + ); + + test('an old export without runs still imports', () async { + final report = await _svc.importDataReport({ + 'format': kExportFormat, + 'version': 2, + 'settings': [], + }); + expect(report.rows, 0); + expect(await _svc.listRuns(), isEmpty); + }); + + test( + 'a runs entry that is not a list is skipped, the rest merges', + () async { + final report = await _svc.importDataReport({ + 'settings': [], + 'runs': 5, + }); + expect(report.rows, 0); + }, + ); + + test('an export holding only runs is accepted; junk is not', () async { + await _insertAll(); + final runs = (await _svc.exportData())['runs']! as List; + await _freshInstall(); + expect((await _svc.importDataReport({'runs': runs})).rows, 3); + await expectLater( + _svc.importDataReport({'runs': 'nope'}), + throwsFormatException, + ); + }); + + test( + 'bad entries are skipped, good ones kept, imports stay never', + () async { + await _insertAll(); + final good = ((await _svc.exportData())['runs']! as List) + .cast>(); + await _freshInstall(); + final forged = { + ...good.first, + 'source': 'runnerup_import', + 'upload_state': 'pending', + }; + final clash = {...good[1], 'id': 'other-id'}; // same hash, new id + final badPoints = {...good[2], 'points': 'not json'}; + final badSport = {...good[2], 'id': 'z', 'sport': 'Cycling'}; + final noId = {...good[2], 'id': ''}; + + final report = await _svc.importDataReport({ + 'runs': [forged, good[1], clash, badPoints, badSport, noId, 'str', 7], + }); + + expect(report.rows, 2); + final stored = await _svc.listRuns(); + expect(stored, hasLength(2)); + final coerced = stored.firstWhere((r) => r.track.id == forged['id']); + expect(coerced.track.source, RunSource.runnerupImport); + expect(coerced.uploadState, RunUploadState.never); + }, + ); + }); +}