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..0b8abc5 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().runIfNeeded()); 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..aed8e32 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/runnerup_importer.dart @@ -0,0 +1,222 @@ +/// 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; + } + + 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) { + tally.fail('listing the PC history failed: $error'); + return const []; + } + final accounted = []; + for (final name in remote) { + try { + final bytes = await fetchProcessedTcx(_client, login, name); + if (await _ingest(name, bytes, tally)) accounted.add(name); + } on Exception catch (error) { + tally.fail('$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; false 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 false; + } + // 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 true; + } + + /// 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/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..964edd1 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/_runnerup_fake_dufs.dart @@ -0,0 +1,103 @@ +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/services/dufs_credentials.dart'; +import 'package:workout_app/services/runnerup_importer.dart'; +import 'package:workout_app/services/storage_service.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(); 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..97ee414 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/runnerup_importer_failures_test.dart @@ -0,0 +1,174 @@ +@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'], 3); + 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('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..d1dd586 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/runnerup_importer_test.dart @@ -0,0 +1,124 @@ +@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'); + expect(marker['count'], 3); + expect(marker['tcx_names'], dufs.files.keys.toList()..sort()); + }, + ); + + 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('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..3c898a5 --- /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'], 3); + }); + + 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/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; + }); +}