Back up and export GPS runs

runs/run_points were in neither backup.json nor the user export. App-made runs go in the backup (history returns from the PC), all runs in export v3; merge keyed by id or hash, idempotent. The backup clobber guard now also protects a runs-only backup.

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:06:56 +01:00
parent 63caf9dee9
commit 8ca4c301fd
9 changed files with 400 additions and 10 deletions

View File

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

View File

@ -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']);
});
}
}

View File

@ -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
: [

View File

@ -24,7 +24,7 @@ extension StorageServiceImport on StorageService {
/// [ImportReport.manualSkipped] says so. Idempotent.
Future<ImportReport> importDataReport(Map<String, dynamic> 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']);

View File

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

View File

@ -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<List<Map<String, Object?>>> _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<String> _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<int> _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<String, dynamic> ? _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<String, Object?>, List<List<Object?>>)? _parseRun(
Map<String, dynamic> 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<dynamic>)
[
(p as List<dynamic>)[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;
}
}
}

View File

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

View File

@ -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<void>.delayed(const Duration(milliseconds: 100));
StorageService.resetForTesting();
final raw = await databaseFactory.openDatabase(path);

View File

@ -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<void> _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<void> _freshInstall() async {
StorageService.resetForTesting();
await StorageService.init();
}
Future<Map<String, dynamic>> _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<void>.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<void>.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<String, dynamic>;
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': <Object?>[],
});
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': <Object?>[],
'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<dynamic>;
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<dynamic>)
.cast<Map<String, Object?>>();
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);
},
);
});
}