mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Merge branch 'worktree-agent-a19044abadd522d41'
This commit is contained in:
commit
801e303b39
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,7 @@ part 'storage_service_export.dart';
|
||||
part 'storage_service_import.dart';
|
||||
part 'storage_service_pc_link.dart';
|
||||
part 'storage_service_runs.dart';
|
||||
part 'storage_service_runs_transfer.dart';
|
||||
part 'storage_service_sandbox.dart';
|
||||
part 'storage_service_schema.dart';
|
||||
part 'storage_service_sessions.dart';
|
||||
|
||||
@ -22,16 +22,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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,9 @@ part of 'storage_service.dart';
|
||||
const kExportFormat = 'workout_app-export';
|
||||
|
||||
/// The format written now. Version 2 adds `updated_at` to `exercise_state` and
|
||||
/// `settings` rows plus `manual_workouts`; version 1 files still import.
|
||||
const kExportVersion = 2;
|
||||
/// `settings` rows plus `manual_workouts`; version 3 adds `runs` (with their
|
||||
/// points); version 1 and 2 files still import.
|
||||
const kExportVersion = 3;
|
||||
|
||||
const _exportTables = [
|
||||
'workout_history',
|
||||
@ -47,6 +48,7 @@ extension StorageServiceExport on StorageService {
|
||||
for (final row in settings)
|
||||
if (!_isPerInstallSetting(row['key']! as String)) row,
|
||||
],
|
||||
'runs': await _runsForTransfer(appOnly: false),
|
||||
'manual_workouts': manual == null
|
||||
? null
|
||||
: [
|
||||
|
||||
@ -24,7 +24,7 @@ extension StorageServiceImport on StorageService {
|
||||
/// [ImportReport.manualSkipped] says so. Idempotent.
|
||||
Future<ImportReport> importDataReport(Map<String, dynamic> data) async {
|
||||
if (!_exportTables.any((t) => data[t] is List)) {
|
||||
if (data['manual_workouts'] is! List) {
|
||||
if (data['manual_workouts'] is! List && data['runs'] is! List) {
|
||||
throw const FormatException('not a workout_app export');
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,7 @@ extension StorageServiceImport on StorageService {
|
||||
for (final table in _exportTables) {
|
||||
rows += await _mergeTable(txn, table, data[table], fileWins, stamped);
|
||||
}
|
||||
rows += await _mergeRuns(txn, data['runs']);
|
||||
});
|
||||
if (rows > 0) unawaited(_backupNow());
|
||||
final manual = await _importManual(data['manual_workouts']);
|
||||
|
||||
@ -61,7 +61,7 @@ extension StorageServiceRuns on StorageService {
|
||||
'an imported run must be RunUploadState.never',
|
||||
);
|
||||
}
|
||||
return await _db.transaction((txn) async {
|
||||
final added = await _db.transaction((txn) async {
|
||||
final clash = await txn.query(
|
||||
'runs',
|
||||
columns: ['id'],
|
||||
@ -95,6 +95,9 @@ extension StorageServiceRuns on StorageService {
|
||||
await batch.commit(noResult: true);
|
||||
return true;
|
||||
});
|
||||
// A recorded run exists nowhere else yet; the RunnerUp history does (PC).
|
||||
if (added && track.source == RunSource.app) unawaited(_backupNow());
|
||||
return added;
|
||||
}
|
||||
|
||||
/// Whether a run with this TCX hash is stored.
|
||||
|
||||
@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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('.'));
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -50,9 +50,10 @@ void main() {
|
||||
return await _row('exercise_state', _name);
|
||||
}
|
||||
|
||||
test('export is version 2 and rows carry updated_at', () async {
|
||||
test('export is the current version (3) and rows carry updated_at', () async {
|
||||
final data = await _svc.exportData();
|
||||
expect(data['version'], 2);
|
||||
expect(data['version'], kExportVersion);
|
||||
expect(kExportVersion, 3);
|
||||
expect(
|
||||
(await _row('exercise_state', _name)).containsKey('updated_at'),
|
||||
true,
|
||||
|
||||
@ -169,6 +169,9 @@ void main() {
|
||||
await StorageService.init();
|
||||
final t = _track();
|
||||
await _insert(t);
|
||||
// A recorded run triggers an unawaited backup; let it finish before the
|
||||
// tables are dropped under it.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
StorageService.resetForTesting();
|
||||
|
||||
final raw = await databaseFactory.openDatabase(path);
|
||||
|
||||
@ -0,0 +1,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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user