diff --git a/stronglift_replacement/workout_app/DOCS-workout-app-context.md b/stronglift_replacement/workout_app/DOCS-workout-app-context.md index 3c893ae..598bd30 100644 --- a/stronglift_replacement/workout_app/DOCS-workout-app-context.md +++ b/stronglift_replacement/workout_app/DOCS-workout-app-context.md @@ -29,3 +29,15 @@ Implementing design_v2.md improvements. - Thresholds that decide credit (3.5 km / 25 min) live on the PC only. - GPS sits behind `PositionSource` (`position_source*.dart`); imported RunnerUp runs are stored with `upload_state='never'` (table CHECK). +- RunnerUp history import (`runnerup_importer.dart`): once, on app start, on + the phone only (never the sandbox). PROPFINDs `/RunnerUp/processed/`, stores + every `.tcx` as `runnerup_import`/`never`, then PUTs the dotfile marker + `/RunnerUp/.workout_app_import.json` = `{"completed_at": ISO-UTC, "count": N, + "tcx_names": [...]}`. The importer never PUTs a `.tcx` (a top-level one would + be credited by `runnerup_watch.sh`). A reinstall re-imports and rewrites the + marker with a new `completed_at`: the PC must not use it as a clock. +- `runs`/`run_points` travel in backup.json (app-made runs only; the RunnerUp + history comes back from the PC) and in the user export (version 3, all runs). + Points are one compact JSON string per run. +- dufs login: `dufs_credentials.dart` (`dufs_url`, `dufs_user`, `dufs_password` + in secure storage). diff --git a/stronglift_replacement/workout_app/lib/main.dart b/stronglift_replacement/workout_app/lib/main.dart index c6445c6..792b8b2 100644 --- a/stronglift_replacement/workout_app/lib/main.dart +++ b/stronglift_replacement/workout_app/lib/main.dart @@ -14,6 +14,7 @@ import 'package:workout_app/services/http_server_service.dart'; import 'package:workout_app/services/lock_mode.dart'; import 'package:workout_app/services/pc_pairing.dart'; import 'package:workout_app/services/progression_sync_service.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_device_id.dart'; import 'package:workout_app/services/sync_service.dart'; @@ -120,6 +121,9 @@ void main(List args) async { if (!Platform.isLinux && !Sandbox.enabled) { await HttpServerService.instance.start(); } + // 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().runGuarded()); runApp(const WorkoutApp()); } // coverage:ignore-end diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart index 5c946dc..0fcc974 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart @@ -2,9 +2,8 @@ /// settings sheet, plus links to the sync surfaces. /// /// "Sync settings" is the shared `sync_settings_ui` package (Firebase sync; -/// no Backup section -- see the class doc on [SettingsScreen] for why). -/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]) because -/// the shared package has no GitHub surface at all. +/// no Backup section -- see [SettingsScreen]). "Advanced sync (GitHub)" stays +/// app-local ([GitHubMirrorScreen]): the shared package has no GitHub surface. library; import 'dart:async'; @@ -26,6 +25,7 @@ import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; import 'package:workout_app/widgets/data_transfer_buttons.dart'; import 'package:workout_app/widgets/exercise_settings_sheet.dart'; +import 'package:workout_app/widgets/runnerup_import_badge.dart'; part 'settings_screen_actions.dart'; part 'settings_screen_deload.dart'; diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart index 068a38c..1a937bd 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_sections.dart @@ -179,6 +179,7 @@ class _SettingsAppBar extends StatelessWidget implements PreferredSizeWidget { title: Text('Settings', style: TextStyle(color: colorScheme.onSurface)), iconTheme: IconThemeData(color: colorScheme.onSurface), actions: [ + const RunnerUpImportBadge(), TextButton( onPressed: loading ? null : onReset, child: Text( diff --git a/stronglift_replacement/workout_app/lib/services/dufs_credentials.dart b/stronglift_replacement/workout_app/lib/services/dufs_credentials.dart new file mode 100644 index 0000000..516ebd8 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/dufs_credentials.dart @@ -0,0 +1,74 @@ +/// Login for the PC's dufs WebDAV server (`/RunnerUp`, scoped read-write). +/// +/// One shared definition so the run uploader and the RunnerUp history +/// importer read the very same secure-storage slots; provisioned once from a +/// `dufs_login.json` that carries `url`, `user` and `password`. +library; + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Secure-storage key of [DufsCredentials.url]. +const String kDufsUrlKey = 'dufs_url'; + +/// Secure-storage key of [DufsCredentials.user]. +const String kDufsUserKey = 'dufs_user'; + +/// Secure-storage key of [DufsCredentials.password]. +const String kDufsPasswordKey = 'dufs_password'; + +/// Where the PC's dufs is and who to log in as. +@immutable +class DufsCredentials { + /// Creates a login; trailing slashes of [url] are dropped so + /// `'$url/RunnerUp/...'` is always well formed. + DufsCredentials({ + required String url, + required this.user, + required this.password, + }) : url = url.replaceFirst(RegExp(r'/+$'), ''); + + /// Parses a `dufs_login.json` document; null when a field is missing. + static DufsCredentials? fromJson(Map json) { + final url = json['url']; + final user = json['user']; + final password = json['password']; + if (url is! String || user is! String || password is! String) return null; + if (url.isEmpty || user.isEmpty || password.isEmpty) return null; + return DufsCredentials(url: url, user: user, password: password); + } + + /// The origin, e.g. `https://kuhy-cloud.duckdns.org` (no trailing slash). + final String url; + + /// The Basic-auth user. + final String user; + + /// The Basic-auth password. + final String password; + + /// The `Authorization` header value. + String get authorization => + 'Basic ${base64Encode(utf8.encode('$user:$password'))}'; + + /// The stored login, or null when any of the three slots is empty. + static Future load({ + FlutterSecureStorage storage = const FlutterSecureStorage(), + }) async { + final url = await storage.read(key: kDufsUrlKey); + final user = await storage.read(key: kDufsUserKey); + final password = await storage.read(key: kDufsPasswordKey); + return fromJson({'url': url, 'user': user, 'password': password}); + } + + /// Stores this login. + Future save({ + FlutterSecureStorage storage = const FlutterSecureStorage(), + }) async { + await storage.write(key: kDufsUrlKey, value: url); + await storage.write(key: kDufsUserKey, value: user); + await storage.write(key: kDufsPasswordKey, value: password); + } +} diff --git a/stronglift_replacement/workout_app/lib/services/runnerup_dav.dart b/stronglift_replacement/workout_app/lib/services/runnerup_dav.dart new file mode 100644 index 0000000..4375903 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/runnerup_dav.dart @@ -0,0 +1,122 @@ +/// The WebDAV half of the RunnerUp history import: list, fetch, marker PUT. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:workout_app/services/dufs_credentials.dart'; +import 'package:xml/xml.dart'; + +/// Folder on the PC that holds the already-handled RunnerUp exports. +const String kRunnerUpProcessedPath = '/RunnerUp/processed/'; + +/// The ONE file the app ever writes to the PC for the import: the +/// import-complete marker. +/// +/// A dotfile at the top of `/RunnerUp` is invisible to `runnerup_watch.sh` +/// (it skips names starting with '.') and to every `*.tcx` glob, so it can +/// never be credited as a workout. [putImportMarker] is the importer's only +/// PUT and its path is this constant, never built from a file name. +const String kImportMarkerPath = '/RunnerUp/.workout_app_import.json'; + +/// Per-request ceiling, so a half-open connection cannot hang the import. +const Duration kDavTimeout = Duration(seconds: 20); + +/// A request the server did not answer with success. +class DavException implements Exception { + /// Creates the exception. + const DavException(this.what); + + /// A sentence naming the request and why it failed. + final String what; + + @override + String toString() => what; +} + +/// The `.tcx` file names directly inside `/RunnerUp/processed/`. +/// +/// One PROPFIND with `Depth: 1`. Throws [DavException] on a non-multistatus +/// answer (401 included) and an `XmlException` on unreadable XML. +Future> listProcessedTcx( + http.Client client, + DufsCredentials login, +) async { + final request = http.Request( + 'PROPFIND', + Uri.parse('${login.url}$kRunnerUpProcessedPath'), + )..headers.addAll({'Authorization': login.authorization, 'Depth': '1'}); + final response = await _send(client, request); + if (response.statusCode != 207) { + throw DavException( + 'PROPFIND $kRunnerUpProcessedPath answered ${response.statusCode}', + ); + } + return tcxNamesFromMultistatus(utf8.decode(response.bodyBytes)); +} + +/// The `.tcx` basenames named by the `href`s of a multistatus [xml]. +/// +/// Hrefs may be absolute paths or full URLs and are percent-encoded; the +/// collection itself (a trailing slash) has no name and is skipped. +List tcxNamesFromMultistatus(String xml) { + final names = {}; + for (final href in XmlDocument.parse( + xml, + ).descendantElements.where((e) => e.localName == 'href')) { + final path = Uri.decodeFull(Uri.parse(href.innerText.trim()).path); + final name = path.substring(path.lastIndexOf('/') + 1); + if (name.toLowerCase().endsWith('.tcx') && !name.startsWith('.')) { + names.add(name); + } + } + return names.toList()..sort(); +} + +/// Downloads one export from `/RunnerUp/processed/` as raw bytes (the caller +/// decides what a non-UTF-8 file means). +Future fetchProcessedTcx( + http.Client client, + DufsCredentials login, + String name, +) async { + final request = http.Request( + 'GET', + Uri.parse( + '${login.url}$kRunnerUpProcessedPath${Uri.encodeComponent(name)}', + ), + )..headers['Authorization'] = login.authorization; + final response = await _send(client, request); + if (response.statusCode != 200) { + throw DavException('GET $name answered ${response.statusCode}'); + } + return response.bodyBytes; +} + +/// PUTs [body] to [kImportMarkerPath]; throws [DavException] unless 2xx. +Future putImportMarker( + http.Client client, + DufsCredentials login, + String body, +) async { + final request = + http.Request('PUT', Uri.parse('${login.url}$kImportMarkerPath')) + ..headers.addAll({ + 'Authorization': login.authorization, + 'Content-Type': 'application/json', + }) + ..body = body; + final response = await _send(client, request); + if (response.statusCode < 200 || response.statusCode > 299) { + throw DavException( + 'PUT $kImportMarkerPath answered ${response.statusCode}', + ); + } +} + +Future _send(http.Client client, http.Request request) async { + final streamed = await client.send(request).timeout(kDavTimeout); + return await http.Response.fromStream(streamed); +} diff --git a/stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart b/stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart new file mode 100644 index 0000000..4d7c169 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/runnerup_import_state.dart @@ -0,0 +1,79 @@ +/// What the RunnerUp import remembers and reports: prefs keys, the live +/// status the badge shows, and the per-pass tally. +library; + +import 'package:flutter/foundation.dart'; + +/// Where RunnerUp itself leaves exports on the phone. +const String kRunnerUpLocalDir = '/sdcard/Documents/RunnerUp'; + +/// `shared_preferences` key: every remote export is in the database. +const String kImportDoneKey = 'runnerup_import_done'; + +/// `shared_preferences` key: the marker JSON, kept until the PC has it. +const String kImportMarkerKey = 'runnerup_import_marker'; + +/// What the badge shows. +enum RunnerUpImportStatus { + /// Not started, or not applicable here (sandbox, desktop). + idle, + + /// Importing right now. + running, + + /// Imported and the PC has been told. + done, + + /// Something failed; the next app start retries. + failed, +} + +/// The live status; the badge listens to it. +final ValueNotifier runnerUpImportStatus = ValueNotifier( + RunnerUpImportStatus.idle, +); + +/// What one [RunnerUpImporter.runIfNeeded] pass did. +typedef RunnerUpImportReport = ({ + int imported, + int duplicates, + int skipped, + List failures, +}); + +/// The report of a pass that did nothing. +const RunnerUpImportReport kNothingImported = ( + imported: 0, + duplicates: 0, + skipped: 0, + failures: [], +); + +/// Counts one import pass and logs each failure as it happens. +class ImportTally { + /// Runs stored this pass. + int imported = 0; + + /// Exports already stored (same hash or id). + int duplicates = 0; + + /// Files that are no usable run, skipped for good. + int skipped = 0; + + /// Why the pass is incomplete; empty means it can finish. + final List failures = []; + + /// Records [reason] and logs it as a warning. + void fail(String reason) { + failures.add(reason); + debugPrint('WorkoutApp: RunnerUp import WARNING: $reason'); + } + + /// The tally as a report. + RunnerUpImportReport get report => ( + imported: imported, + duplicates: duplicates, + skipped: skipped, + failures: failures, + ); +} diff --git a/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart new file mode 100644 index 0000000..7edbd2f --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart @@ -0,0 +1,255 @@ +/// One-time import of the RunnerUp history into the app's `runs` table. +/// +/// Imported runs are display-only: [RunSource.runnerupImport] with +/// [RunUploadState.never]. Nothing here ever PUTs a `.tcx` anywhere; the only +/// write to the PC is the dotfile marker (see [kImportMarkerPath]) that tells +/// the PC the import finished. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/sandbox/sandbox_log.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/dufs_credentials.dart'; +import 'package:workout_app/services/runnerup_dav.dart'; +import 'package:workout_app/services/runnerup_import_state.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/tcx_codec.dart'; + +export 'package:workout_app/services/runnerup_import_state.dart'; + +/// Imports the RunnerUp history once, on app start. +class RunnerUpImporter { + /// Creates an importer; every collaborator can be replaced in tests. + RunnerUpImporter({ + http.Client? client, + Future Function()? loadLogin, + Future Function()? canReadStorage, + this.localDir = kRunnerUpLocalDir, + bool? isAndroid, + DateTime Function()? now, + }) : _client = client ?? http.Client(), + _loadLogin = loadLogin ?? DufsCredentials.load, + _canReadStorage = + canReadStorage ?? BackupService.instance.hasStoragePermission, + _isAndroid = isAndroid ?? Platform.isAndroid, + _now = now ?? DateTime.now; + + final http.Client _client; + final Future Function() _loadLogin; + final Future Function() _canReadStorage; + final bool _isAndroid; + final DateTime Function() _now; + + /// Where RunnerUp's own exports are looked for (when storage is readable). + final String localDir; + + /// Runs the import unless it already completed, this is the sandbox, or + /// this is not the phone (a desktop run would write the marker before the + /// phone ever imported). + Future runIfNeeded() async { + if (Sandbox.enabled) { + SandboxLog.event('runnerup import skipped: sandbox never imports'); + return kNothingImported; + } + if (!_isAndroid) { + debugPrint('WorkoutApp: RunnerUp import skipped: not the phone.'); + return kNothingImported; + } + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(kImportDoneKey) ?? false) { + final marker = prefs.getString(kImportMarkerKey); + if (marker == null) { + runnerUpImportStatus.value = RunnerUpImportStatus.done; + return kNothingImported; + } + return ( + imported: 0, + duplicates: 0, + skipped: 0, + failures: await _deliver(prefs, marker), + ); + } + runnerUpImportStatus.value = RunnerUpImportStatus.running; + final report = await _importAll(prefs); + runnerUpImportStatus.value = report.failures.isEmpty + ? RunnerUpImportStatus.done + : RunnerUpImportStatus.failed; + 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(); + final names = []; + if (login == null) { + tally.fail('no dufs login in secure storage, so nothing was fetched'); + } else { + names.addAll(await _importRemote(login, tally)); + } + await _importLocal(tally); + if (tally.failures.isEmpty && login != null) { + final marker = jsonEncode({ + 'completed_at': _now().toUtc().toIso8601String(), + 'count': names.length, + 'tcx_names': names, + }); + await prefs.setString(kImportMarkerKey, marker); + await prefs.setBool(kImportDoneKey, true); + tally.failures.addAll(await _deliver(prefs, marker)); + } + return tally.report; + } + + /// Fetches and stores every remote export; returns the names now accounted + /// for (stored, or already stored). A file that is not a usable run is + /// skipped for good and left out; a network failure is a failure. + Future> _importRemote( + DufsCredentials login, + ImportTally tally, + ) async { + final List remote; + try { + remote = await listProcessedTcx(_client, login); + } on Exception catch (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); + 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) { + debugPrint('WorkoutApp: RunnerUp import WARNING: $name: $error'); + tally.failures.add('$name: $error'); + } + await Future.delayed(Duration.zero); + } + return accounted; + } + + /// Best effort: only if storage is already readable, and never a failure. + Future _importLocal(ImportTally tally) async { + if (!await _canReadStorage()) { + debugPrint( + 'WorkoutApp: RunnerUp import: storage not readable, skipping ' + '$localDir (the PC copy is the source).', + ); + return; + } + try { + final dir = Directory(localDir); + if (!dir.existsSync()) return; + for (final file in dir.listSync().whereType().where( + (f) => f.path.toLowerCase().endsWith('.tcx'), + )) { + final name = file.path.substring(file.path.lastIndexOf('/') + 1); + await _ingest(name, await file.readAsBytes(), tally); + } + } on Exception catch (error) { + debugPrint( + 'WorkoutApp: RunnerUp import WARNING: reading $localDir failed ' + '($error); the PC copy still completes the import.', + ); + } + } + + /// 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 { + xml = utf8.decode(bytes); + decoded = decodeTcx(xml, fileName: name); + } on FormatException catch (error) { + tally.skipped++; + debugPrint( + 'WorkoutApp: RunnerUp import WARNING: $name is not a usable run ' + '($error); skipped for good.', + ); + 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. + final track = RunTrack( + id: decoded.id, + sport: decoded.sport, + startedAt: decoded.startedAt, + points: decoded.points, + totalSeconds: decoded.totalSeconds, + distanceM: decoded.distanceM, + source: RunSource.runnerupImport, + ); + final added = await StorageService.instance.insertRun( + track, + tcxFilename: name, + tcxSha256: tcxSha256(xml), + uploadState: RunUploadState.never, + ); + added ? tally.imported++ : tally.duplicates++; + return decoded.source; + } + + /// PUTs the marker; returns the failures (empty once the PC has it). + Future> _deliver(SharedPreferences prefs, String marker) async { + final login = await _loadLogin(); + String? failure; + if (login == null) { + failure = 'no dufs login in secure storage, so the PC was not told'; + } else { + try { + await putImportMarker(_client, login, marker); + } on Exception catch (error) { + failure = 'telling the PC failed: $error'; + } + } + if (failure != null) { + debugPrint('WorkoutApp: RunnerUp import WARNING: $failure'); + runnerUpImportStatus.value = RunnerUpImportStatus.failed; + return [failure]; + } + await prefs.remove(kImportMarkerKey); + runnerUpImportStatus.value = RunnerUpImportStatus.done; + return const []; + } +} 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..21208d9 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,23 @@ 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) { final existing = await BackupService.instance.readBackup(); final existingHistory = (existing?['workout_history'] as List?) ?? const []; - if (existingHistory.isNotEmpty) { + final existingRuns = (existing?['runs'] as List?) ?? const []; + // 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 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 +50,7 @@ extension StorageServiceBackup on StorageService { 'workout_history': historyRows, 'settings': settingsRows, 'progression_events': eventRows, + 'runs': runRows, }); } @@ -173,6 +181,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..b5c6a90 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_runs_transfer.dart @@ -0,0 +1,137 @@ +// 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 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/lib/widgets/runnerup_import_badge.dart b/stronglift_replacement/workout_app/lib/widgets/runnerup_import_badge.dart new file mode 100644 index 0000000..59717ee --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/runnerup_import_badge.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; + +/// The RunnerUp history import's state as a glyph, never as words: a small +/// spinner while it runs, a warning glyph when it failed (the next start +/// retries), nothing when it is idle or finished. +class RunnerUpImportBadge extends StatelessWidget { + /// Creates the badge; [status] defaults to the importer's live status. + const RunnerUpImportBadge({super.key, this.status}); + + /// Overrides [runnerUpImportStatus] (tests). + final ValueNotifier? status; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: status ?? runnerUpImportStatus, + builder: (context, state, _) => switch (state) { + RunnerUpImportStatus.running => const Padding( + padding: EdgeInsets.all(14), + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + RunnerUpImportStatus.failed => Padding( + padding: const EdgeInsets.all(14), + child: Icon( + Icons.sync_problem, + color: Theme.of(context).colorScheme.error, + ), + ), + RunnerUpImportStatus.idle || + RunnerUpImportStatus.done => const SizedBox.shrink(), + }, + ); + } +} diff --git a/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart b/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart new file mode 100644 index 0000000..f3b7d6a --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; +import 'dart:io'; +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'; + +/// The three golden exports, by file name. +Map fixtureTcx() => { + for (final f in Directory(kFixtureDir).listSync().whereType()) + if (f.path.endsWith('.tcx')) f.uri.pathSegments.last: f.readAsBytesSync(), +}; + +final DufsCredentials kLogin = DufsCredentials( + url: 'https://dufs.test/', + user: 'runnerup', + password: 'pw', +); + +Future fakeLogin() async => kLogin; + +/// A dufs stand-in that records every request; nothing touches a network. +class FakeDufs { + FakeDufs(this.files); + + /// `processed/` contents by name. + final Map files; + + /// `METHOD /path` of every request, in order. + final List requests = []; + + /// The marker bodies PUT so far. + final List markers = []; + + int listStatus = 207; + int markerStatus = 201; + Set failingGets = {}; + bool unreachable = false; + String? depth; + + late final MockClient client = MockClient((request) async { + requests.add('${request.method} ${request.url.path}'); + if (unreachable) throw const SocketException('network is down'); + if (request.headers['Authorization'] != kLogin.authorization) { + return http.Response('unauthorized', 401); + } + if (request.method == 'PROPFIND') { + depth = request.headers['Depth']; + return http.Response( + listStatus == 207 ? multistatus(files.keys) : 'denied', + listStatus, + ); + } + if (request.method == 'PUT') { + markers.add(request.body); + return http.Response('', markerStatus); + } + final name = Uri.decodeComponent(request.url.pathSegments.last); + if (failingGets.contains(name)) return http.Response('boom', 500); + return http.Response.bytes(files[name]!, 200); + }); + + Iterable get puts => requests.where((r) => r.startsWith('PUT ')); + Iterable get gets => requests.where((r) => r.startsWith('GET ')); +} + +/// A dufs-shaped multistatus: the collection first, then each file. +String multistatus(Iterable names) => + ''' + + +/RunnerUp/processed/ + +HTTP/1.1 200 OK +${names.map((n) => '/RunnerUp/processed/${Uri.encodeComponent(n)}HTTP/1.1 200 OK').join('\n')} +/RunnerUp/processed/same.gpx +'''; + +Uint8List utf8Bytes(String s) => Uint8List.fromList(utf8.encode(s)); + +final DateTime kNow = DateTime.utc(2026, 11, 10, 8); + +RunnerUpImporter makeImporter( + FakeDufs dufs, { + Future Function()? canRead, + String localDir = '/nonexistent/RunnerUp', + bool android = true, +}) => RunnerUpImporter( + client: dufs.client, + loadLogin: fakeLogin, + canReadStorage: canRead ?? () async => false, + localDir: localDir, + isAndroid: android, + now: () => kNow, +); + +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 new file mode 100644 index 0000000..689069f --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart @@ -0,0 +1,189 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/sandbox/sandbox_log.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '_runnerup_fake_dufs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late FakeDufs dufs; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + Sandbox.enabled = false; + runnerUpImportStatus.value = RunnerUpImportStatus.idle; + StorageService.resetForTesting(); + await StorageService.init(); + dufs = FakeDufs(fixtureTcx()); + }); + + tearDown(() => Sandbox.enabled = false); + + test( + '401 leaves it incomplete and failed, then a fix completes it', + () async { + dufs.listStatus = 401; + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.failures.single, contains('401')); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + expect(dufs.puts, isEmpty); + expect(await storedRuns(), isEmpty); + + dufs.listStatus = 207; + final retry = await makeImporter(dufs).runIfNeeded(); + expect(retry.failures, isEmpty); + expect(retry.imported, 3); + expect(dufs.puts, hasLength(1)); + }, + ); + + test( + 'one failed download keeps it incomplete; the rest are stored', + () async { + final bad = dufs.files.keys.first; + dufs.failingGets = {bad}; + + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.failures.single, contains(bad)); + expect(report.imported, 2); + expect(dufs.puts, isEmpty); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + + dufs.failingGets = {}; + final retry = await makeImporter(dufs).runIfNeeded(); + expect(retry.failures, isEmpty); + expect(retry.imported, 1); + expect(retry.duplicates, 2); + expect(dufs.puts, hasLength(1)); + expect(await storedRuns(), hasLength(3)); + }, + ); + + test('an unreachable PC is a failure, not a crash', () async { + dufs.unreachable = true; + final report = await makeImporter(dufs).runIfNeeded(); + expect(report.failures.single, contains('network is down')); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + }); + + test('a file that is no usable run is skipped for good', () async { + dufs.files['junk_Running.tcx'] = utf8Bytes('not xml'); + dufs.files['bytes_Running.tcx'] = Uint8List.fromList([0xff, 0xfe, 0xfd]); + + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.failures, isEmpty); + expect(report.skipped, 2); + final marker = jsonDecode(dufs.markers.single) as Map; + expect(marker['count'], expectedMarkerNames(fixtureTcx()).length); + expect(marker['tcx_names'], isNot(contains('junk_Running.tcx'))); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.done); + }); + + test( + 'a failed marker PUT keeps the import and retries only the PUT', + () async { + dufs.markerStatus = 500; + final first = await makeImporter(dufs).runIfNeeded(); + + expect(first.failures.single, contains('500')); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + expect(await storedRuns(), hasLength(3)); + + dufs.markerStatus = 201; + dufs.requests.clear(); + final retry = await makeImporter(dufs).runIfNeeded(); + + expect(retry.failures, isEmpty); + expect(dufs.requests, ['PUT /RunnerUp/.workout_app_import.json']); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.done); + dufs.requests.clear(); + await makeImporter(dufs).runIfNeeded(); + expect( + dufs.requests, + isEmpty, + reason: 'marker delivered: truly finished', + ); + }, + ); + + test('without a dufs login it fails loudly and fetches nothing', () async { + final importer = RunnerUpImporter( + client: dufs.client, + loadLogin: () async => null, + canReadStorage: () async => false, + isAndroid: true, + ); + final report = await importer.runIfNeeded(); + expect(report.failures.single, contains('no dufs login')); + expect(dufs.requests, isEmpty); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed); + }); + + test('the login vanishing before the marker PUT is a failure too', () async { + var calls = 0; + final importer = RunnerUpImporter( + client: dufs.client, + loadLogin: () async => calls++ == 0 ? kLogin : null, + canReadStorage: () async => false, + isAndroid: true, + ); + final report = await importer.runIfNeeded(); + expect(report.failures.single, contains('PC was not told')); + 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(); + await SandboxLog.flush(); + expect(report.imported, 0); + expect(dufs.requests, isEmpty); + expect(await storedRuns(), isEmpty); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle); + }); + + test('off the phone it does nothing (no early marker)', () async { + await makeImporter(dufs, android: false).runIfNeeded(); + expect(dufs.requests, isEmpty); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle); + }); + + test('with every default it is inert on the desktop test host', () async { + final report = await RunnerUpImporter().runIfNeeded(); + expect(report.imported, 0); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart b/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart new file mode 100644 index 0000000..c379b2d --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart @@ -0,0 +1,147 @@ +@TestOn('vm') +library; + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.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/sandbox/sandbox.dart'; +import 'package:workout_app/services/runnerup_dav.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'; + +import '_runnerup_fake_dufs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late FakeDufs dufs; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + Sandbox.enabled = false; + runnerUpImportStatus.value = RunnerUpImportStatus.idle; + StorageService.resetForTesting(); + await StorageService.init(); + dufs = FakeDufs(fixtureTcx()); + }); + + tearDown(() => Sandbox.enabled = false); + + test( + 'imports the PC history as display-only runs, then tells the PC', + () async { + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.failures, isEmpty); + expect(report.imported, 3); + expect(dufs.depth, '1'); + final runs = await storedRuns(); + expect(runs, hasLength(3)); + for (final run in runs) { + expect(run.track.source, RunSource.runnerupImport); + expect(run.uploadState, RunUploadState.never); + } + // The walk is identified by its file name alone. + expect(runs.map((r) => r.track.sport), contains(RunSport.walking)); + // The multi-lap RunnerUp file is summed like the PC sums it. + final multi = runs.firstWhere((r) => r.tcxFilename.contains('08-12')); + expect(multi.track.distanceM, greaterThan(0)); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.done); + + 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'); + final names = expectedMarkerNames(dufs.files); + expect(names, isNotEmpty); + expect(marker['count'], names.length); + expect(marker['tcx_names'], names); + }, + ); + + test( + 'an app-made TCX that came back is still history, never pending', + () async { + final name = dufs.files.keys.firstWhere((n) => n.contains('17-30-00')); + expect( + decodeTcx(utf8.decode(dufs.files[name]!), fileName: name).source, + RunSource.app, + reason: + 'fixture must be an app-made file for this test to mean anything', + ); + await makeImporter(dufs).runIfNeeded(); + final stored = (await storedRuns()).firstWhere( + (r) => r.tcxFilename == name, + ); + expect(stored.track.source, RunSource.runnerupImport); + expect(stored.uploadState, RunUploadState.never); + }, + ); + + 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)); + expect(dufs.puts.single, startsWith('PUT /RunnerUp/.')); + expect(dufs.puts.single, isNot(contains('.tcx'))); + }); + + test('a rerun is a no-op once complete: no requests at all', () async { + await makeImporter(dufs).runIfNeeded(); + final before = dufs.requests.length; + + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.imported, 0); + expect(dufs.requests, hasLength(before)); + expect(runnerUpImportStatus.value, RunnerUpImportStatus.done); + expect(await storedRuns(), hasLength(3)); + }); + + test( + 'forgetting completion re-imports nothing new (dedupe by hash)', + () async { + await makeImporter(dufs).runIfNeeded(); + SharedPreferences.setMockInitialValues({}); + + final report = await makeImporter(dufs).runIfNeeded(); + + expect(report.imported, 0); + expect(report.duplicates, 3); + expect(await storedRuns(), hasLength(3)); + }, + ); + + test('the marker path is a dotfile at the top of /RunnerUp', () { + expect(kImportMarkerPath, '/RunnerUp/.workout_app_import.json'); + expect(kImportMarkerPath.split('/').last, startsWith('.')); + }); +} 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 new file mode 100644 index 0000000..30c9907 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/runnerup_local_and_dav_test.dart @@ -0,0 +1,167 @@ +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/services/dufs_credentials.dart'; +import 'package:workout_app/services/runnerup_dav.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '../fake_secure_storage.dart'; +import '_runnerup_fake_dufs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + group('the phone folder scan', () { + late Directory dir; + late FakeDufs dufs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + StorageService.resetForTesting(); + await StorageService.init(); + dufs = FakeDufs(fixtureTcx()); + dir = Directory.systemTemp.createTempSync('runnerup_local'); + addTearDown(() => dir.deleteSync(recursive: true)); + }); + + RunnerUpImporter importer({bool canRead = true}) => RunnerUpImporter( + client: dufs.client, + loadLogin: fakeLogin, + canReadStorage: () async => canRead, + localDir: dir.path, + isAndroid: true, + ); + + test('adds a phone-only export, dedupes the one the PC has', () async { + final shared = dufs.files.entries.first; + File('${dir.path}/${shared.key}').writeAsBytesSync(shared.value); + final onlyHere = utf8 + .decode(dufs.files.entries.last.value) + .replaceAll('2026-11-09T', '2026-11-08T'); + File( + '${dir.path}/RunnerUp_2026-11-08-17-30-00_Running.tcx', + ).writeAsStringSync(onlyHere); + File('${dir.path}/notes.txt').writeAsStringSync('ignored'); + + final report = await importer().runIfNeeded(); + + expect(report.failures, isEmpty); + expect(report.imported, 4); + expect(report.duplicates, 1); + final stored = await StorageService.instance.listRuns(); + 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'], expectedMarkerNames(dufs.files).length); + }); + + test('an unreadable file warns but never blocks completion', () async { + final locked = File('${dir.path}/locked_Running.tcx') + ..writeAsStringSync('x'); + Process.runSync('chmod', ['000', locked.path]); + + final report = await importer().runIfNeeded(); + + expect(report.failures, isEmpty); + expect(dufs.puts, hasLength(1)); + }); + + test('a missing folder is fine', () async { + final report = await RunnerUpImporter( + client: dufs.client, + loadLogin: fakeLogin, + canReadStorage: () async => true, + localDir: '${dir.path}/missing', + isAndroid: true, + ).runIfNeeded(); + expect(report.failures, isEmpty); + expect(report.imported, 3); + }); + }); + + group('multistatus parsing', () { + test('keeps tcx names only, decoded, once each', () { + const xml = ''' + + +/RunnerUp/processed/ +/RunnerUp/processed/a%20b_Running.tcx +https://h.test/RunnerUp/processed/C_Walking.TCX +/RunnerUp/processed/C_Walking.TCX +/RunnerUp/processed/a.gpx +/RunnerUp/processed/.hidden.tcx +'''; + expect(tcxNamesFromMultistatus(xml), [ + 'C_Walking.TCX', + 'a b_Running.tcx', + ]); + }); + + test('a 207 that is not XML is an error the caller can report', () { + expect(() => tcxNamesFromMultistatus('<<<'), throwsA(isA())); + }); + + test('DavException reads as its sentence', () { + expect( + const DavException('GET x answered 500').toString(), + contains('500'), + ); + }); + }); + + group('DufsCredentials', () { + test('round-trips through secure storage under the shared keys', () async { + installFakeSecureStorage(); + expect(await DufsCredentials.load(), isNull); + + await DufsCredentials( + url: 'https://h.test///', + user: 'u', + password: 'p', + ).save(); + final back = await DufsCredentials.load(); + + expect(back!.url, 'https://h.test'); + expect(back.user, 'u'); + expect(back.password, 'p'); + expect(back.authorization, 'Basic ${base64Encode(utf8.encode('u:p'))}'); + expect( + [kDufsUrlKey, kDufsUserKey, kDufsPasswordKey], + ['dufs_url', 'dufs_user', 'dufs_password'], + ); + }); + + test('a half-provisioned login is no login', () async { + installFakeSecureStorage(initial: {kDufsUrlKey: 'https://h.test'}); + expect(await DufsCredentials.load(), isNull); + }); + + test('dufs_login.json needs all three non-empty strings', () { + expect( + DufsCredentials.fromJson({'url': 'u', 'user': 'a', 'password': 'b'}), + isNotNull, + ); + expect(DufsCredentials.fromJson({'url': 'u', 'user': 'a'}), isNull); + expect( + DufsCredentials.fromJson({'url': 'u', 'user': '', 'password': 'b'}), + isNull, + ); + expect( + DufsCredentials.fromJson({'url': 1, 'user': 'a', 'password': 'b'}), + isNull, + ); + }); + }); +} 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..5a934e1 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_runs_transfer_test.dart @@ -0,0 +1,270 @@ +@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), + ); + }); + }); + + 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', + () 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); + }, + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/runnerup_import_badge_test.dart b/stronglift_replacement/workout_app/test/widgets/runnerup_import_badge_test.dart new file mode 100644 index 0000000..3eabd79 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/runnerup_import_badge_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; +import 'package:workout_app/widgets/runnerup_import_badge.dart'; + +void main() { + Future pump( + WidgetTester tester, + ValueNotifier? status, + ) => tester.pumpWidget( + MaterialApp( + home: Scaffold(body: RunnerUpImportBadge(status: status)), + ), + ); + + testWidgets('shows a spinner while importing, a glyph on failure, and ' + 'never any text', (tester) async { + final status = ValueNotifier(RunnerUpImportStatus.idle); + await pump(tester, status); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byIcon(Icons.sync_problem), findsNothing); + + status.value = RunnerUpImportStatus.running; + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + status.value = RunnerUpImportStatus.failed; + await tester.pump(); + expect(find.byIcon(Icons.sync_problem), findsOneWidget); + expect(find.byType(Text), findsNothing); + + status.value = RunnerUpImportStatus.done; + await tester.pump(); + expect(find.byIcon(Icons.sync_problem), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('listens to the live importer status by default', (tester) async { + runnerUpImportStatus.value = RunnerUpImportStatus.failed; + await pump(tester, null); + expect(find.byIcon(Icons.sync_problem), findsOneWidget); + runnerUpImportStatus.value = RunnerUpImportStatus.idle; + }); +}