mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Import RunnerUp history into the app
One-time, phone-only import of /RunnerUp/processed over dufs WebDAV as display-only runs, then a dotfile marker PUT for the PC. Never PUTs a tcx. Icon-only status badge in Settings, shared DufsCredentials. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
parent
8ca4c301fd
commit
a6c97dab8f
@ -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().runIfNeeded());
|
||||
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,222 @@
|
||||
/// One-time import of the RunnerUp history into the app's `runs` table.
|
||||
///
|
||||
/// Imported runs are display-only: [RunSource.runnerupImport] with
|
||||
/// [RunUploadState.never]. Nothing here ever PUTs a `.tcx` anywhere; the only
|
||||
/// write to the PC is the dotfile marker (see [kImportMarkerPath]) that tells
|
||||
/// the PC the import finished.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/sandbox/sandbox_log.dart';
|
||||
import 'package:workout_app/services/backup_service.dart';
|
||||
import 'package:workout_app/services/dufs_credentials.dart';
|
||||
import 'package:workout_app/services/runnerup_dav.dart';
|
||||
import 'package:workout_app/services/runnerup_import_state.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
export 'package:workout_app/services/runnerup_import_state.dart';
|
||||
|
||||
/// Imports the RunnerUp history once, on app start.
|
||||
class RunnerUpImporter {
|
||||
/// Creates an importer; every collaborator can be replaced in tests.
|
||||
RunnerUpImporter({
|
||||
http.Client? client,
|
||||
Future<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;
|
||||
}
|
||||
|
||||
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) {
|
||||
tally.fail('listing the PC history failed: $error');
|
||||
return const [];
|
||||
}
|
||||
final accounted = <String>[];
|
||||
for (final name in remote) {
|
||||
try {
|
||||
final bytes = await fetchProcessedTcx(_client, login, name);
|
||||
if (await _ingest(name, bytes, tally)) accounted.add(name);
|
||||
} on Exception catch (error) {
|
||||
tally.fail('$name: $error');
|
||||
}
|
||||
await Future<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; false when it is not a usable run.
|
||||
Future<bool> _ingest(String name, Uint8List bytes, ImportTally tally) async {
|
||||
final String xml;
|
||||
final RunTrack decoded;
|
||||
try {
|
||||
xml = utf8.decode(bytes);
|
||||
decoded = decodeTcx(xml, fileName: name);
|
||||
} on FormatException catch (error) {
|
||||
tally.skipped++;
|
||||
debugPrint(
|
||||
'WorkoutApp: RunnerUp import WARNING: $name is not a usable run '
|
||||
'($error); skipped for good.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Forced, whatever the file says: an app-made TCX that came back through
|
||||
// processed/ must not be re-uploaded, so it is history, never pending.
|
||||
final track = RunTrack(
|
||||
id: decoded.id,
|
||||
sport: decoded.sport,
|
||||
startedAt: decoded.startedAt,
|
||||
points: decoded.points,
|
||||
totalSeconds: decoded.totalSeconds,
|
||||
distanceM: decoded.distanceM,
|
||||
source: RunSource.runnerupImport,
|
||||
);
|
||||
final added = await StorageService.instance.insertRun(
|
||||
track,
|
||||
tcxFilename: name,
|
||||
tcxSha256: tcxSha256(xml),
|
||||
uploadState: RunUploadState.never,
|
||||
);
|
||||
added ? tally.imported++ : tally.duplicates++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// PUTs the marker; returns the failures (empty once the PC has it).
|
||||
Future<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 [];
|
||||
}
|
||||
}
|
||||
@ -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,103 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/services/dufs_credentials.dart';
|
||||
import 'package:workout_app/services/runnerup_importer.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
const String kFixtureDir = '../../screen_locker/tests/fixtures/run_tcx';
|
||||
|
||||
/// The three golden exports, by file name.
|
||||
Map<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();
|
||||
@ -0,0 +1,174 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/sandbox/sandbox_log.dart';
|
||||
import 'package:workout_app/services/runnerup_importer.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
|
||||
import '_runnerup_fake_dufs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late FakeDufs dufs;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
Sandbox.enabled = false;
|
||||
runnerUpImportStatus.value = RunnerUpImportStatus.idle;
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
dufs = FakeDufs(fixtureTcx());
|
||||
});
|
||||
|
||||
tearDown(() => Sandbox.enabled = false);
|
||||
|
||||
test(
|
||||
'401 leaves it incomplete and failed, then a fix completes it',
|
||||
() async {
|
||||
dufs.listStatus = 401;
|
||||
final report = await makeImporter(dufs).runIfNeeded();
|
||||
|
||||
expect(report.failures.single, contains('401'));
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
|
||||
expect(dufs.puts, isEmpty);
|
||||
expect(await storedRuns(), isEmpty);
|
||||
|
||||
dufs.listStatus = 207;
|
||||
final retry = await makeImporter(dufs).runIfNeeded();
|
||||
expect(retry.failures, isEmpty);
|
||||
expect(retry.imported, 3);
|
||||
expect(dufs.puts, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'one failed download keeps it incomplete; the rest are stored',
|
||||
() async {
|
||||
final bad = dufs.files.keys.first;
|
||||
dufs.failingGets = {bad};
|
||||
|
||||
final report = await makeImporter(dufs).runIfNeeded();
|
||||
|
||||
expect(report.failures.single, contains(bad));
|
||||
expect(report.imported, 2);
|
||||
expect(dufs.puts, isEmpty);
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
|
||||
|
||||
dufs.failingGets = {};
|
||||
final retry = await makeImporter(dufs).runIfNeeded();
|
||||
expect(retry.failures, isEmpty);
|
||||
expect(retry.imported, 1);
|
||||
expect(retry.duplicates, 2);
|
||||
expect(dufs.puts, hasLength(1));
|
||||
expect(await storedRuns(), hasLength(3));
|
||||
},
|
||||
);
|
||||
|
||||
test('an unreachable PC is a failure, not a crash', () async {
|
||||
dufs.unreachable = true;
|
||||
final report = await makeImporter(dufs).runIfNeeded();
|
||||
expect(report.failures.single, contains('network is down'));
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
|
||||
});
|
||||
|
||||
test('a file that is no usable run is skipped for good', () async {
|
||||
dufs.files['junk_Running.tcx'] = utf8Bytes('not xml');
|
||||
dufs.files['bytes_Running.tcx'] = Uint8List.fromList([0xff, 0xfe, 0xfd]);
|
||||
|
||||
final report = await makeImporter(dufs).runIfNeeded();
|
||||
|
||||
expect(report.failures, isEmpty);
|
||||
expect(report.skipped, 2);
|
||||
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
|
||||
expect(marker['count'], 3);
|
||||
expect(marker['tcx_names'], isNot(contains('junk_Running.tcx')));
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.done);
|
||||
});
|
||||
|
||||
test(
|
||||
'a failed marker PUT keeps the import and retries only the PUT',
|
||||
() async {
|
||||
dufs.markerStatus = 500;
|
||||
final first = await makeImporter(dufs).runIfNeeded();
|
||||
|
||||
expect(first.failures.single, contains('500'));
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
|
||||
expect(await storedRuns(), hasLength(3));
|
||||
|
||||
dufs.markerStatus = 201;
|
||||
dufs.requests.clear();
|
||||
final retry = await makeImporter(dufs).runIfNeeded();
|
||||
|
||||
expect(retry.failures, isEmpty);
|
||||
expect(dufs.requests, ['PUT /RunnerUp/.workout_app_import.json']);
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.done);
|
||||
dufs.requests.clear();
|
||||
await makeImporter(dufs).runIfNeeded();
|
||||
expect(
|
||||
dufs.requests,
|
||||
isEmpty,
|
||||
reason: 'marker delivered: truly finished',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('without a dufs login it fails loudly and fetches nothing', () async {
|
||||
final importer = RunnerUpImporter(
|
||||
client: dufs.client,
|
||||
loadLogin: () async => null,
|
||||
canReadStorage: () async => false,
|
||||
isAndroid: true,
|
||||
);
|
||||
final report = await importer.runIfNeeded();
|
||||
expect(report.failures.single, contains('no dufs login'));
|
||||
expect(dufs.requests, isEmpty);
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
|
||||
});
|
||||
|
||||
test('the login vanishing before the marker PUT is a failure too', () async {
|
||||
var calls = 0;
|
||||
final importer = RunnerUpImporter(
|
||||
client: dufs.client,
|
||||
loadLogin: () async => calls++ == 0 ? kLogin : null,
|
||||
canReadStorage: () async => false,
|
||||
isAndroid: true,
|
||||
);
|
||||
final report = await importer.runIfNeeded();
|
||||
expect(report.failures.single, contains('PC was not told'));
|
||||
expect(dufs.puts, isEmpty);
|
||||
});
|
||||
|
||||
test('the sandbox never imports', () async {
|
||||
Sandbox.enabled = true;
|
||||
final report = await makeImporter(dufs).runIfNeeded();
|
||||
await SandboxLog.flush();
|
||||
expect(report.imported, 0);
|
||||
expect(dufs.requests, isEmpty);
|
||||
expect(await storedRuns(), isEmpty);
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle);
|
||||
});
|
||||
|
||||
test('off the phone it does nothing (no early marker)', () async {
|
||||
await makeImporter(dufs, android: false).runIfNeeded();
|
||||
expect(dufs.requests, isEmpty);
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle);
|
||||
});
|
||||
|
||||
test('with every default it is inert on the desktop test host', () async {
|
||||
final report = await RunnerUpImporter().runIfNeeded();
|
||||
expect(report.imported, 0);
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:workout_app/models/run_record.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/services/runnerup_dav.dart';
|
||||
import 'package:workout_app/services/runnerup_importer.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/tcx_codec.dart';
|
||||
|
||||
import '_runnerup_fake_dufs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late FakeDufs dufs;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
Sandbox.enabled = false;
|
||||
runnerUpImportStatus.value = RunnerUpImportStatus.idle;
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
dufs = FakeDufs(fixtureTcx());
|
||||
});
|
||||
|
||||
tearDown(() => Sandbox.enabled = false);
|
||||
|
||||
test(
|
||||
'imports the PC history as display-only runs, then tells the PC',
|
||||
() async {
|
||||
final report = await makeImporter(dufs).runIfNeeded();
|
||||
|
||||
expect(report.failures, isEmpty);
|
||||
expect(report.imported, 3);
|
||||
expect(dufs.depth, '1');
|
||||
final runs = await storedRuns();
|
||||
expect(runs, hasLength(3));
|
||||
for (final run in runs) {
|
||||
expect(run.track.source, RunSource.runnerupImport);
|
||||
expect(run.uploadState, RunUploadState.never);
|
||||
}
|
||||
// The walk is identified by its file name alone.
|
||||
expect(runs.map((r) => r.track.sport), contains(RunSport.walking));
|
||||
// The multi-lap RunnerUp file is summed like the PC sums it.
|
||||
final multi = runs.firstWhere((r) => r.tcxFilename.contains('08-12'));
|
||||
expect(multi.track.distanceM, greaterThan(0));
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.done);
|
||||
|
||||
expect(dufs.puts, ['PUT /RunnerUp/.workout_app_import.json']);
|
||||
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
|
||||
expect(marker['completed_at'], '2026-11-10T08:00:00.000Z');
|
||||
expect(marker['count'], 3);
|
||||
expect(marker['tcx_names'], dufs.files.keys.toList()..sort());
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'an app-made TCX that came back is still history, never pending',
|
||||
() async {
|
||||
final name = dufs.files.keys.firstWhere((n) => n.contains('17-30-00'));
|
||||
expect(
|
||||
decodeTcx(utf8.decode(dufs.files[name]!), fileName: name).source,
|
||||
RunSource.app,
|
||||
reason:
|
||||
'fixture must be an app-made file for this test to mean anything',
|
||||
);
|
||||
await makeImporter(dufs).runIfNeeded();
|
||||
final stored = (await storedRuns()).firstWhere(
|
||||
(r) => r.tcxFilename == name,
|
||||
);
|
||||
expect(stored.track.source, RunSource.runnerupImport);
|
||||
expect(stored.uploadState, RunUploadState.never);
|
||||
},
|
||||
);
|
||||
|
||||
test('never PUTs a tcx anywhere; the only write is the dotfile', () async {
|
||||
await makeImporter(dufs).runIfNeeded();
|
||||
expect(dufs.puts, hasLength(1));
|
||||
expect(dufs.puts.single, startsWith('PUT /RunnerUp/.'));
|
||||
expect(dufs.puts.single, isNot(contains('.tcx')));
|
||||
});
|
||||
|
||||
test('a rerun is a no-op once complete: no requests at all', () async {
|
||||
await makeImporter(dufs).runIfNeeded();
|
||||
final before = dufs.requests.length;
|
||||
|
||||
final report = await makeImporter(dufs).runIfNeeded();
|
||||
|
||||
expect(report.imported, 0);
|
||||
expect(dufs.requests, hasLength(before));
|
||||
expect(runnerUpImportStatus.value, RunnerUpImportStatus.done);
|
||||
expect(await storedRuns(), hasLength(3));
|
||||
});
|
||||
|
||||
test(
|
||||
'forgetting completion re-imports nothing new (dedupe by hash)',
|
||||
() async {
|
||||
await makeImporter(dufs).runIfNeeded();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
final report = await makeImporter(dufs).runIfNeeded();
|
||||
|
||||
expect(report.imported, 0);
|
||||
expect(report.duplicates, 3);
|
||||
expect(await storedRuns(), hasLength(3));
|
||||
},
|
||||
);
|
||||
|
||||
test('the marker path is a dotfile at the top of /RunnerUp', () {
|
||||
expect(kImportMarkerPath, '/RunnerUp/.workout_app_import.json');
|
||||
expect(kImportMarkerPath.split('/').last, startsWith('.'));
|
||||
});
|
||||
}
|
||||
@ -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'], 3);
|
||||
});
|
||||
|
||||
test('an unreadable file warns but never blocks completion', () async {
|
||||
final locked = File('${dir.path}/locked_Running.tcx')
|
||||
..writeAsStringSync('x');
|
||||
Process.runSync('chmod', ['000', locked.path]);
|
||||
|
||||
final report = await importer().runIfNeeded();
|
||||
|
||||
expect(report.failures, isEmpty);
|
||||
expect(dufs.puts, hasLength(1));
|
||||
});
|
||||
|
||||
test('a missing folder is fine', () async {
|
||||
final report = await RunnerUpImporter(
|
||||
client: dufs.client,
|
||||
loadLogin: fakeLogin,
|
||||
canReadStorage: () async => true,
|
||||
localDir: '${dir.path}/missing',
|
||||
isAndroid: true,
|
||||
).runIfNeeded();
|
||||
expect(report.failures, isEmpty);
|
||||
expect(report.imported, 3);
|
||||
});
|
||||
});
|
||||
|
||||
group('multistatus parsing', () {
|
||||
test('keeps tcx names only, decoded, once each', () {
|
||||
const xml = '''
|
||||
<?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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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