mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +01:00
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:
parent
63caf9dee9
commit
8ca4c301fd
@ -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';
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
: [
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user