Merge branch 'worktree-agent-a19044abadd522d41'

This commit is contained in:
Krzysztof Rudnicki 2026-11-10 16:12:27 +01:00
commit 801e303b39
23 changed files with 1690 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String, dynamic> 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<DufsCredentials?> 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<void> 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);
}
}

View File

@ -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<List<String>> 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<String> tcxNamesFromMultistatus(String xml) {
final names = <String>{};
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<Uint8List> 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<void> 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<http.Response> _send(http.Client client, http.Request request) async {
final streamed = await client.send(request).timeout(kDavTimeout);
return await http.Response.fromStream(streamed);
}

View File

@ -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> runnerUpImportStatus = ValueNotifier(
RunnerUpImportStatus.idle,
);
/// What one [RunnerUpImporter.runIfNeeded] pass did.
typedef RunnerUpImportReport = ({
int imported,
int duplicates,
int skipped,
List<String> 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<String> 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,
);
}

View File

@ -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<DufsCredentials?> Function()? loadLogin,
Future<bool> 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<DufsCredentials?> Function() _loadLogin;
final Future<bool> 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<RunnerUpImportReport> 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<RunnerUpImportReport> runGuarded() async {
try {
return await runIfNeeded();
} on Object catch (error) {
debugPrint('WorkoutApp: RunnerUp import WARNING: aborted: $error');
runnerUpImportStatus.value = RunnerUpImportStatus.failed;
return (
imported: 0,
duplicates: 0,
skipped: 0,
failures: ['aborted: $error'],
);
}
}
Future<RunnerUpImportReport> _importAll(SharedPreferences prefs) async {
final tally = ImportTally();
final login = await _loadLogin();
final names = <String>[];
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<List<String>> _importRemote(
DufsCredentials login,
ImportTally tally,
) async {
final List<String> 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 = <String>[];
final seen = <String>{};
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<void>.delayed(Duration.zero);
}
return accounted;
}
/// Best effort: only if storage is already readable, and never a failure.
Future<void> _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<File>().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<RunSource?> _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<List<String>> _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 [];
}
}

View File

@ -34,6 +34,7 @@ part 'storage_service_export.dart';
part 'storage_service_import.dart';
part 'storage_service_pc_link.dart';
part 'storage_service_runs.dart';
part 'storage_service_runs_transfer.dart';
part 'storage_service_sandbox.dart';
part 'storage_service_schema.dart';
part 'storage_service_sessions.dart';

View File

@ -22,16 +22,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']);
});
}
}

View File

@ -7,8 +7,9 @@ part of 'storage_service.dart';
const kExportFormat = 'workout_app-export';
/// The format written now. Version 2 adds `updated_at` to `exercise_state` and
/// `settings` rows plus `manual_workouts`; version 1 files still import.
const kExportVersion = 2;
/// `settings` rows plus `manual_workouts`; version 3 adds `runs` (with their
/// points); version 1 and 2 files still import.
const kExportVersion = 3;
const _exportTables = [
'workout_history',
@ -47,6 +48,7 @@ extension StorageServiceExport on StorageService {
for (final row in settings)
if (!_isPerInstallSetting(row['key']! as String)) row,
],
'runs': await _runsForTransfer(appOnly: false),
'manual_workouts': manual == null
? null
: [

View File

@ -24,7 +24,7 @@ extension StorageServiceImport on StorageService {
/// [ImportReport.manualSkipped] says so. Idempotent.
Future<ImportReport> importDataReport(Map<String, dynamic> data) async {
if (!_exportTables.any((t) => data[t] is List)) {
if (data['manual_workouts'] is! List) {
if (data['manual_workouts'] is! List && data['runs'] is! List) {
throw const FormatException('not a workout_app export');
}
}
@ -36,6 +36,7 @@ extension StorageServiceImport on StorageService {
for (final table in _exportTables) {
rows += await _mergeTable(txn, table, data[table], fileWins, stamped);
}
rows += await _mergeRuns(txn, data['runs']);
});
if (rows > 0) unawaited(_backupNow());
final manual = await _importManual(data['manual_workouts']);

View File

@ -61,7 +61,7 @@ extension StorageServiceRuns on StorageService {
'an imported run must be RunUploadState.never',
);
}
return await _db.transaction((txn) async {
final added = await _db.transaction((txn) async {
final clash = await txn.query(
'runs',
columns: ['id'],
@ -95,6 +95,9 @@ extension StorageServiceRuns on StorageService {
await batch.commit(noResult: true);
return true;
});
// A recorded run exists nowhere else yet; the RunnerUp history does (PC).
if (added && track.source == RunSource.app) unawaited(_backupNow());
return added;
}
/// Whether a run with this TCX hash is stored.

View File

@ -0,0 +1,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<List<Map<String, Object?>>> _runsForTransfer({
required bool appOnly,
}) async {
final rows = await _db.query(
'runs',
where: appOnly ? "source = 'app'" : null,
orderBy: 'started_at, id',
);
return [
for (final row in rows)
{...row, 'points': await _encodePoints(row['id']! as String)},
];
}
Future<String> _encodePoints(String runId) async {
final points = await _db.query(
'run_points',
where: 'run_id = ?',
whereArgs: [runId],
orderBy: 'seq',
);
return jsonEncode([
for (final p in points)
[p['time_ms'], p['lat'], p['lon'], p['alt'], p['distance_m']],
]);
}
/// Merges transferred [raw] runs into the database and returns how many
/// were new. Idempotent: a run whose id OR TCX hash is already stored is
/// left alone (and so are its points); a malformed entry is counted and
/// skipped, never fatal. An imported run is stored as `never` whatever the
/// file claims, since the table's CHECK would reject anything else.
Future<int> _mergeRuns(Transaction txn, Object? raw) async {
if (raw is! List) {
if (raw != null) _warnSkipped('runs', 1);
return 0;
}
final known = await txn.query('runs', columns: ['id', 'tcx_sha256']);
final ids = {for (final r in known) r['id']! as String};
final shas = {for (final r in known) r['tcx_sha256']! as String};
var written = 0;
var malformed = 0;
for (final entry in raw) {
final parsed = entry is Map<String, dynamic> ? _parseRun(entry) : null;
if (parsed == null) {
malformed++;
continue;
}
final (row, points) = parsed;
final id = row['id']! as String;
final sha = row['tcx_sha256']! as String;
if (ids.contains(id) || shas.contains(sha)) continue;
ids.add(id);
shas.add(sha);
await txn.insert('runs', row);
final batch = txn.batch();
for (final (seq, p) in points.indexed) {
batch.insert('run_points', {
'run_id': row['id'],
'seq': seq,
'time_ms': p[0],
'lat': p[1],
'lon': p[2],
'alt': p[3],
'distance_m': p[4],
});
}
await batch.commit(noResult: true);
written++;
}
if (malformed > 0) _warnSkipped('runs', malformed);
return written;
}
/// The `runs` row and decoded points of [e], or null when it is malformed.
(Map<String, Object?>, List<List<Object?>>)? _parseRun(
Map<String, dynamic> e,
) {
try {
final source = RunSource.fromStorageKey(e['source'] as String);
final state = source == RunSource.runnerupImport
? RunUploadState.never
: RunUploadState.fromStorageKey(e['upload_state'] as String);
final points = [
for (final p in jsonDecode(e['points'] as String) as List<dynamic>)
[
(p as List<dynamic>)[0] as int,
(p[1] as num).toDouble(),
(p[2] as num).toDouble(),
(p[3] as num?)?.toDouble(),
(p[4] as num).toDouble(),
],
];
final sport = RunSport.fromTcxName(e['sport'] as String)!.tcxName;
final id = e['id'] as String;
final sha = e['tcx_sha256'] as String;
if (id.isEmpty || sha.isEmpty) return null;
return (
{
'id': id,
'sport': sport,
'started_at': e['started_at'] as int,
'total_seconds': e['total_seconds'] as int,
'distance_m': (e['distance_m'] as num).toDouble(),
'source': source.storageKey,
'upload_state': state.storageKey,
'tcx_filename': e['tcx_filename'] as String,
'tcx_sha256': sha,
},
points,
);
} on Object catch (error) {
// Wrong shape, unknown enum key, or a points string that is not JSON;
// the caller also logs the total via _warnSkipped.
debugPrint('WorkoutApp: malformed run entry skipped: $error');
return null;
}
}
}

View File

@ -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<RunnerUpImportStatus>? status;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<RunnerUpImportStatus>(
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(),
},
);
}
}

View File

@ -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<String, Uint8List> fixtureTcx() => {
for (final f in Directory(kFixtureDir).listSync().whereType<File>())
if (f.path.endsWith('.tcx')) f.uri.pathSegments.last: f.readAsBytesSync(),
};
final DufsCredentials kLogin = DufsCredentials(
url: 'https://dufs.test/',
user: 'runnerup',
password: 'pw',
);
Future<DufsCredentials?> 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<String, Uint8List> files;
/// `METHOD /path` of every request, in order.
final List<String> requests = [];
/// The marker bodies PUT so far.
final List<String> markers = [];
int listStatus = 207;
int markerStatus = 201;
Set<String> 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<String> get puts => requests.where((r) => r.startsWith('PUT '));
Iterable<String> get gets => requests.where((r) => r.startsWith('GET '));
}
/// A dufs-shaped multistatus: the collection first, then each file.
String multistatus(Iterable<String> names) =>
'''
<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response><D:href>/RunnerUp/processed/</D:href><D:propstat><D:prop>
<D:resourcetype><D:collection/></D:resourcetype></D:prop>
<D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
${names.map((n) => '<D:response><D:href>/RunnerUp/processed/${Uri.encodeComponent(n)}</D:href><D:propstat><D:prop><D:resourcetype></D:resourcetype></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>').join('\n')}
<D:response><D:href>/RunnerUp/processed/same.gpx</D:href></D:response>
</D:multistatus>''';
Uint8List utf8Bytes(String s) => Uint8List.fromList(utf8.encode(s));
final DateTime kNow = DateTime.utc(2026, 11, 10, 8);
RunnerUpImporter makeImporter(
FakeDufs dufs, {
Future<bool> 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<List<RunRecord>> storedRuns() => StorageService.instance.listRuns();
/// What the marker must list for [files]: every RunnerUp-authored run once
/// (a `Pixel_6a_` twin is the same run), never an app-authored file.
List<String> expectedMarkerNames(Map<String, Uint8List> files) {
final seen = <String>{};
return [
for (final name in files.keys.toList()..sort())
if (decodeTcx(utf8.decode(files[name]!), fileName: name).source ==
RunSource.runnerupImport &&
seen.add(name.replaceFirst('Pixel_6a_', '')))
name,
];
}

View File

@ -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<String, dynamic>;
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);
});
}

View File

@ -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<String, dynamic>;
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 <Id>, different bytes: the phone's own copy of the export.
dufs.files[twin] = utf8Bytes('${utf8.decode(dufs.files[original]!)}\n');
final report = await makeImporter(dufs).runIfNeeded();
expect(report.failures, isEmpty);
expect(report.duplicates, 1);
expect(await storedRuns(), hasLength(3));
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
final names = (marker['tcx_names'] as List<dynamic>).cast<String>();
expect(names.where((n) => n.contains('08-12')), [original]);
expect(marker['count'], names.length);
expect(names.any((n) => n.contains('17-30-00')), isFalse);
},
);
test('never PUTs a tcx anywhere; the only write is the dotfile', () async {
await makeImporter(dufs).runIfNeeded();
expect(dufs.puts, hasLength(1));
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('.'));
});
}

View File

@ -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<String, dynamic>;
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 = '''
<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:">
<D:response><D:href>/RunnerUp/processed/</D:href></D:response>
<D:response><D:href>/RunnerUp/processed/a%20b_Running.tcx</D:href></D:response>
<D:response><D:href>https://h.test/RunnerUp/processed/C_Walking.TCX</D:href></D:response>
<D:response><D:href>/RunnerUp/processed/C_Walking.TCX</D:href></D:response>
<D:response><D:href>/RunnerUp/processed/a.gpx</D:href></D:response>
<D:response><D:href>/RunnerUp/processed/.hidden.tcx</D:href></D:response>
</D:multistatus>''';
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<Exception>()));
});
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,
);
});
});
}

View File

@ -50,9 +50,10 @@ void main() {
return await _row('exercise_state', _name);
}
test('export is version 2 and rows carry updated_at', () async {
test('export is the current version (3) and rows carry updated_at', () async {
final data = await _svc.exportData();
expect(data['version'], 2);
expect(data['version'], kExportVersion);
expect(kExportVersion, 3);
expect(
(await _row('exercise_state', _name)).containsKey('updated_at'),
true,

View File

@ -169,6 +169,9 @@ void main() {
await StorageService.init();
final t = _track();
await _insert(t);
// A recorded run triggers an unawaited backup; let it finish before the
// tables are dropped under it.
await Future<void>.delayed(const Duration(milliseconds: 100));
StorageService.resetForTesting();
final raw = await databaseFactory.openDatabase(path);

View File

@ -0,0 +1,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<void> _insertAll() async {
for (final (name, xml, track) in _tracks()) {
final imported = track.source == RunSource.runnerupImport;
await _svc.insertRun(
track,
tcxFilename: name,
tcxSha256: tcxSha256(xml),
uploadState: imported ? RunUploadState.never : RunUploadState.pending,
);
}
}
Future<void> _freshInstall() async {
StorageService.resetForTesting();
await StorageService.init();
}
Future<Map<String, dynamic>> _backupOnceItHolds(int runs) async {
for (var i = 0; i < 150; i++) {
final b = await BackupService.instance.readBackup();
if (b != null && ((b['runs'] as List?)?.length ?? 0) >= runs) return b;
await Future<void>.delayed(const Duration(milliseconds: 20));
}
fail('backup.json never held $runs run(s)');
}
void main() {
late Directory tmp;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tmp = Directory.systemTemp.createTempSync('runs_transfer');
BackupService.baseDir = tmp.path;
await _freshInstall();
});
tearDown(() {
BackupService.baseDir = kBackupDir;
tmp.deleteSync(recursive: true);
});
group('backup.json', () {
test(
'wipe the DB, restore: recorded runs and their points return',
() async {
await _insertAll();
final backup = await _backupOnceItHolds(2);
final saved = {
for (final r in await _svc.listRuns())
if (r.track.source == RunSource.app)
r.track.id: await _svc.getRun(r.track.id),
};
expect(saved, hasLength(2));
// RunnerUp history is reproducible from the PC: not duplicated here.
expect(
(backup['runs'] as List).map((r) => (r as Map)['source']).toSet(),
{'app'},
);
final text = const JsonEncoder.withIndent(' ').convert(backup);
expect(
text.split('\n').length,
lessThan(200),
reason: 'points must stay one string per run, not one line per fix',
);
await _freshInstall(); // the reinstall
expect(await _svc.listRuns(), isEmpty);
await _svc.restoreFromBackupIfNeeded();
final restored = await _svc.listRuns();
expect(restored.map((r) => r.track.id).toSet(), saved.keys.toSet());
for (final run in saved.values) {
final back = (await _svc.getRun(run!.track.id))!;
expect(back.track.points, run.track.points);
expect(back.track.points, isNotEmpty);
expect(back.uploadState, run.uploadState);
expect(back.tcxSha256, run.tcxSha256);
expect(back.track.totalSeconds, run.track.totalSeconds);
expect(back.track.distanceM, run.track.distanceM);
}
},
);
test('restoring twice stores nothing twice', () async {
await _insertAll();
await _backupOnceItHolds(2);
await _freshInstall();
await _svc.restoreFromBackupIfNeeded();
final once = await _svc.listRuns();
await _svc.restoreFromBackupIfNeeded();
expect(await _svc.listRuns(), hasLength(once.length));
});
test('a run-less fresh DB cannot overwrite a runs-only backup', () async {
await _insertAll();
final before = await _backupOnceItHolds(2);
await _freshInstall();
await _svc.setLastWorkoutType('A'); // triggers _backupNow
await Future<void>.delayed(const Duration(milliseconds: 100));
final after = await BackupService.instance.readBackup();
expect(
after!['runs'] as List,
hasLength((before['runs'] as List).length),
);
});
});
test(
'a run on a history-less DB cannot clobber a backup with history',
() async {
await BackupService.instance.export({
'exercise_state': <Object?>[],
'workout_history': [
{
'date': '2026-07-17',
'workout_type': 'B',
'duration_seconds': 6465,
'succeeded': 1,
'json': '{}',
},
],
'settings': <Object?>[],
});
final (name, xml, track) = _tracks().firstWhere(
(t) => t.$3.source == RunSource.app,
);
await _svc.insertRun(
track,
tcxFilename: name,
tcxSha256: tcxSha256(xml),
uploadState: RunUploadState.pending,
);
await Future<void>.delayed(const Duration(milliseconds: 150));
final after = await BackupService.instance.readBackup();
expect(after!['workout_history'], hasLength(1));
},
);
group('user export / merge-import', () {
test(
'round trip keeps every run and point; a re-import adds nothing',
() async {
await _insertAll();
final originals = {
for (final r in await _svc.listRuns())
r.track.id: (await _svc.getRun(r.track.id))!,
};
final data =
jsonDecode(jsonEncode(await _svc.exportData()))
as Map<String, dynamic>;
expect(data['version'], kExportVersion);
expect(data['runs'], hasLength(3));
await _freshInstall();
final report = await _svc.importDataReport(data);
expect(report.rows, greaterThanOrEqualTo(3));
for (final run in originals.values) {
final back = (await _svc.getRun(run.track.id))!;
expect(back.track.points, run.track.points);
expect(back.track.source, run.track.source);
expect(back.uploadState, run.uploadState);
}
final again = await _svc.importDataReport(data);
expect(again.rows, 0);
expect(await _svc.listRuns(), hasLength(3));
},
);
test('an old export without runs still imports', () async {
final report = await _svc.importDataReport({
'format': kExportFormat,
'version': 2,
'settings': <Object?>[],
});
expect(report.rows, 0);
expect(await _svc.listRuns(), isEmpty);
});
test(
'a runs entry that is not a list is skipped, the rest merges',
() async {
final report = await _svc.importDataReport({
'settings': <Object?>[],
'runs': 5,
});
expect(report.rows, 0);
},
);
test('an export holding only runs is accepted; junk is not', () async {
await _insertAll();
final runs = (await _svc.exportData())['runs']! as List<dynamic>;
await _freshInstall();
expect((await _svc.importDataReport({'runs': runs})).rows, 3);
await expectLater(
_svc.importDataReport({'runs': 'nope'}),
throwsFormatException,
);
});
test(
'bad entries are skipped, good ones kept, imports stay never',
() async {
await _insertAll();
final good = ((await _svc.exportData())['runs']! as List<dynamic>)
.cast<Map<String, Object?>>();
await _freshInstall();
final forged = {
...good.first,
'source': 'runnerup_import',
'upload_state': 'pending',
};
final clash = {...good[1], 'id': 'other-id'}; // same hash, new id
final badPoints = {...good[2], 'points': 'not json'};
final badSport = {...good[2], 'id': 'z', 'sport': 'Cycling'};
final noId = {...good[2], 'id': ''};
final report = await _svc.importDataReport({
'runs': [forged, good[1], clash, badPoints, badSport, noId, 'str', 7],
});
expect(report.rows, 2);
final stored = await _svc.listRuns();
expect(stored, hasLength(2));
final coerced = stored.firstWhere((r) => r.track.id == forged['id']);
expect(coerced.track.source, RunSource.runnerupImport);
expect(coerced.uploadState, RunUploadState.never);
},
);
});
}

View File

@ -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<void> pump(
WidgetTester tester,
ValueNotifier<RunnerUpImportStatus>? 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;
});
}