Marker lists distinct runs; guard fixes

Marker: twins (Pixel_6a_ infix) listed once, app-authored files never. Backup guard: a recorded run no longer lifts the workout-history clobber guard. runGuarded: a throw outside per-file handling logs and sets failed. Silent-failure hook clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 16:11:59 +01:00
parent a6c97dab8f
commit 6fa9666cdd
9 changed files with 141 additions and 17 deletions

View File

@ -123,7 +123,7 @@ void main(List<String> 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

View File

@ -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<RunnerUpImportReport> 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<RunnerUpImportReport> _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 = <String>[];
final seen = <String>{};
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<void>.delayed(Duration.zero);
}
@ -161,8 +189,13 @@ class RunnerUpImporter {
}
}
/// Stores one export; false when it is not a usable run.
Future<bool> _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<RunSource?> _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).

View File

@ -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 '

View File

@ -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;
}
}

View File

@ -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<List<RunRecord>> 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<String> expectedMarkerNames(Map<String, Uint8List> files) {
final seen = <String>{};
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,
];
}

View File

@ -92,7 +92,7 @@ void main() {
expect(report.failures, isEmpty);
expect(report.skipped, 2);
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
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();

View File

@ -60,8 +60,10 @@ void main() {
expect(dufs.puts, ['PUT /RunnerUp/.workout_app_import.json']);
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
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 <Id>, 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<String, dynamic>;
final names = (marker['tcx_names'] as List<dynamic>).cast<String>();
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));

View File

@ -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<String, dynamic>;
expect(marker['count'], 3);
expect(marker['count'], expectedMarkerNames(dufs.files).length);
});
test('an unreadable file warns but never blocks completion', () async {

View File

@ -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': <Object?>[],
'workout_history': [
{
'date': '2026-07-17',
'workout_type': 'B',
'duration_seconds': 6465,
'succeeded': 1,
'json': '{}',
},
],
'settings': <Object?>[],
});
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<void>.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',