diff --git a/stronglift_replacement/workout_app/lib/main.dart b/stronglift_replacement/workout_app/lib/main.dart index 0b8abc5..792b8b2 100644 --- a/stronglift_replacement/workout_app/lib/main.dart +++ b/stronglift_replacement/workout_app/lib/main.dart @@ -123,7 +123,7 @@ void main(List args) async { } // One-time RunnerUp history import (no-op in the sandbox and off-phone, // and once it completed). Off the launch path: it is many downloads. - unawaited(RunnerUpImporter().runIfNeeded()); + unawaited(RunnerUpImporter().runGuarded()); runApp(const WorkoutApp()); } // coverage:ignore-end diff --git a/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart index aed8e32..7edbd2f 100644 --- a/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart +++ b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart @@ -85,6 +85,24 @@ class RunnerUpImporter { return report; } + /// [runIfNeeded] that cannot leave the badge spinning: anything thrown + /// outside the per-file handling (a platform error, a prefs write) becomes + /// a logged failure and the `failed` state, retried next start. + Future runGuarded() async { + try { + return await runIfNeeded(); + } on Object catch (error) { + debugPrint('WorkoutApp: RunnerUp import WARNING: aborted: $error'); + runnerUpImportStatus.value = RunnerUpImportStatus.failed; + return ( + imported: 0, + duplicates: 0, + skipped: 0, + failures: ['aborted: $error'], + ); + } + } + Future _importAll(SharedPreferences prefs) async { final tally = ImportTally(); final login = await _loadLogin(); @@ -119,16 +137,26 @@ class RunnerUpImporter { try { remote = await listProcessedTcx(_client, login); } on Exception catch (error) { - tally.fail('listing the PC history failed: $error'); + debugPrint('WorkoutApp: RunnerUp import WARNING: listing failed: $error'); + tally.failures.add('listing the PC history failed: $error'); return const []; } final accounted = []; + final seen = {}; for (final name in remote) { try { final bytes = await fetchProcessedTcx(_client, login, name); - if (await _ingest(name, bytes, tally)) accounted.add(name); + final source = await _ingest(name, bytes, tally); + // The marker lists each distinct RunnerUp run once (the phone's + // `Pixel_6a_` twin of a file is the same run) and never an + // app-authored file. + if (source == RunSource.runnerupImport && + seen.add(name.replaceFirst('Pixel_6a_', ''))) { + accounted.add(name); + } } on Exception catch (error) { - tally.fail('$name: $error'); + debugPrint('WorkoutApp: RunnerUp import WARNING: $name: $error'); + tally.failures.add('$name: $error'); } await Future.delayed(Duration.zero); } @@ -161,8 +189,13 @@ class RunnerUpImporter { } } - /// Stores one export; false when it is not a usable run. - Future _ingest(String name, Uint8List bytes, ImportTally tally) async { + /// Stores one export; returns what its file says made it (before it is + /// forced to history), or null when it is not a usable run. + Future _ingest( + String name, + Uint8List bytes, + ImportTally tally, + ) async { final String xml; final RunTrack decoded; try { @@ -174,7 +207,7 @@ class RunnerUpImporter { 'WorkoutApp: RunnerUp import WARNING: $name is not a usable run ' '($error); skipped for good.', ); - return false; + return null; } // Forced, whatever the file says: an app-made TCX that came back through // processed/ must not be re-uploaded, so it is history, never pending. @@ -194,7 +227,7 @@ class RunnerUpImporter { uploadState: RunUploadState.never, ); added ? tally.imported++ : tally.duplicates++; - return true; + return decoded.source; } /// PUTs the marker; returns the failures (empty once the PC has it). 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 d0d098a..21208d9 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart @@ -24,12 +24,16 @@ extension StorageServiceBackup on StorageService { final eventRows = await _db.query('progression_events'); final runRows = await _runsForTransfer(appOnly: true); - if (historyRows.isEmpty && runRows.isEmpty) { + if (historyRows.isEmpty) { final existing = await BackupService.instance.readBackup(); final existingHistory = (existing?['workout_history'] as List?) ?? const []; final existingRuns = (existing?['runs'] as List?) ?? const []; - if (existingHistory.isNotEmpty || existingRuns.isNotEmpty) { + // A recorded run must not lift the history guard (a seeded DB that + // then records a run would erase months of sessions); only a DB with + // neither is also barred from erasing a runs-only backup. + if (existingHistory.isNotEmpty || + (runRows.isEmpty && existingRuns.isNotEmpty)) { debugPrint( 'WorkoutApp: REFUSING to overwrite the backup — this DB has no ' 'workout history or runs but the backup holds ' 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 index c65373c..b5c6a90 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart @@ -127,9 +127,10 @@ extension StorageServiceRunsTransfer on StorageService { }, 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. + } on Object catch (error) { + // Wrong shape, unknown enum key, or a points string that is not JSON; + // the caller also logs the total via _warnSkipped. + debugPrint('WorkoutApp: malformed run entry skipped: $error'); return null; } } diff --git a/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart b/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart index 964edd1..f3b7d6a 100644 --- a/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart +++ b/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart @@ -5,9 +5,11 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; import 'package:workout_app/services/dufs_credentials.dart'; import 'package:workout_app/services/runnerup_importer.dart'; import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/tcx_codec.dart'; const String kFixtureDir = '../../screen_locker/tests/fixtures/run_tcx'; @@ -101,3 +103,16 @@ RunnerUpImporter makeImporter( ); Future> storedRuns() => StorageService.instance.listRuns(); + +/// What the marker must list for [files]: every RunnerUp-authored run once +/// (a `Pixel_6a_` twin is the same run), never an app-authored file. +List expectedMarkerNames(Map files) { + final seen = {}; + return [ + for (final name in files.keys.toList()..sort()) + if (decodeTcx(utf8.decode(files[name]!), fileName: name).source == + RunSource.runnerupImport && + seen.add(name.replaceFirst('Pixel_6a_', ''))) + name, + ]; +} diff --git a/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart b/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart index 97ee414..689069f 100644 --- a/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart +++ b/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart @@ -92,7 +92,7 @@ void main() { expect(report.failures, isEmpty); expect(report.skipped, 2); final marker = jsonDecode(dufs.markers.single) as Map; - expect(marker['count'], 3); + expect(marker['count'], expectedMarkerNames(fixtureTcx()).length); expect(marker['tcx_names'], isNot(contains('junk_Running.tcx'))); expect(runnerUpImportStatus.value, RunnerUpImportStatus.done); }); @@ -150,6 +150,21 @@ void main() { expect(dufs.puts, isEmpty); }); + test( + 'a throw outside the per-file handling fails loudly, not forever', + () async { + final importer = RunnerUpImporter( + client: dufs.client, + loadLogin: fakeLogin, + canReadStorage: () async => throw StateError('platform down'), + isAndroid: true, + ); + final report = await importer.runGuarded(); + expect(report.failures.single, contains('platform down')); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + }, + ); + test('the sandbox never imports', () async { Sandbox.enabled = true; final report = await makeImporter(dufs).runIfNeeded(); diff --git a/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart b/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart index d1dd586..c379b2d 100644 --- a/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart +++ b/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart @@ -60,8 +60,10 @@ void main() { expect(dufs.puts, ['PUT /RunnerUp/.workout_app_import.json']); final marker = jsonDecode(dufs.markers.single) as Map; expect(marker['completed_at'], '2026-11-10T08:00:00.000Z'); - expect(marker['count'], 3); - expect(marker['tcx_names'], dufs.files.keys.toList()..sort()); + final names = expectedMarkerNames(dufs.files); + expect(names, isNotEmpty); + expect(marker['count'], names.length); + expect(marker['tcx_names'], names); }, ); @@ -84,6 +86,27 @@ void main() { }, ); + test( + 'twins are one run: stored once, listed once, app files not at all', + () async { + final original = dufs.files.keys.firstWhere((n) => n.contains('08-12')); + final twin = original.replaceFirst('RunnerUp_', 'RunnerUp_Pixel_6a_'); + // Same , different bytes: the phone's own copy of the export. + dufs.files[twin] = utf8Bytes('${utf8.decode(dufs.files[original]!)}\n'); + + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.failures, isEmpty); + expect(report.duplicates, 1); + expect(await storedRuns(), hasLength(3)); + final marker = jsonDecode(dufs.markers.single) as Map; + final names = (marker['tcx_names'] as List).cast(); + expect(names.where((n) => n.contains('08-12')), [original]); + expect(marker['count'], names.length); + expect(names.any((n) => n.contains('17-30-00')), isFalse); + }, + ); + test('never PUTs a tcx anywhere; the only write is the dotfile', () async { await makeImporter(dufs).runIfNeeded(); expect(dufs.puts, hasLength(1)); diff --git a/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart b/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart index 3c898a5..30c9907 100644 --- a/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart +++ b/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart @@ -64,7 +64,7 @@ void main() { expect(stored, hasLength(4)); // The phone-only file is history but is not offered to the PC's list. final marker = jsonDecode(dufs.markers.single) as Map; - expect(marker['count'], 3); + expect(marker['count'], expectedMarkerNames(dufs.files).length); }); test('an unreadable file warns but never blocks completion', () async { 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 index c771a42..5a934e1 100644 --- 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 @@ -141,6 +141,39 @@ void main() { }); }); + test( + 'a run on a history-less DB cannot clobber a backup with history', + () async { + await BackupService.instance.export({ + 'exercise_state': [], + 'workout_history': [ + { + 'date': '2026-07-17', + 'workout_type': 'B', + 'duration_seconds': 6465, + 'succeeded': 1, + 'json': '{}', + }, + ], + 'settings': [], + }); + final (name, xml, track) = _tracks().firstWhere( + (t) => t.$3.source == RunSource.app, + ); + + await _svc.insertRun( + track, + tcxFilename: name, + tcxSha256: tcxSha256(xml), + uploadState: RunUploadState.pending, + ); + await Future.delayed(const Duration(milliseconds: 150)); + + final after = await BackupService.instance.readBackup(); + expect(after!['workout_history'], hasLength(1)); + }, + ); + group('user export / merge-import', () { test( 'round trip keeps every run and point; a re-import adds nothing',