Split backup_service.dart; partly split workout_sync_service.dart

backup_service.dart (261 -> 175) + backup_service_session.dart
  workout_sync_service.dart (415 -> 322) + workout_sync_service_read.dart

An extension method is dispatched STATICALLY, so a method a test fake
overrides cannot move to one: `_FakeSync extends WorkoutSyncService` overrides
pushManual and readMergedManualPayloads, and with those in an extension the
real implementations ran instead of the fakes -- four manual-workout screen
tests hung on pumpAndSettle rather than failing an assertion. Both are back in
the class; only the private _readMergedPayloads/_fetchPayloads/_mergeRecords
helpers moved.

That leaves workout_sync_service.dart at 322: its public surface is what
callers and fakes bind to, so going lower means changing that surface, not
moving text. Static members also need qualifying inside an extension
(BackupService._baseDir).

402 Dart tests; dart analyze clean; flutter coverage gate passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 21:38:42 +02:00
parent 3d992ef71b
commit 9184a6bc33
4 changed files with 212 additions and 191 deletions

View File

@ -18,6 +18,8 @@ import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:permission_handler/permission_handler.dart';
part 'backup_service_session.dart';
/// Default directory backups live in on external storage.
const String kBackupDir = '/sdcard/WorkoutTracker';
@ -170,92 +172,4 @@ class BackupService {
}
// ── Sync token backup ────────────────────────────────────────────────────
/// Mirrors [token] to external storage (deleting the file when empty).
/// Best-effort like [export]; see the library doc for the security
/// trade-off this represents.
Future<void> exportSyncToken(String token) async {
try {
final f = File(_syncTokenPath);
if (token.isEmpty) {
if (f.existsSync()) await f.delete();
return;
}
final dir = Directory(_baseDir);
if (!dir.existsSync()) {
dir.createSync(recursive: true);
}
await f.writeAsString(token);
} on Exception catch (error) {
// Backup is best-effort; never throw.
debugPrint(
'WorkoutApp: could not mirror the sync token to $_syncTokenPath '
'($error) — the token will NOT survive the next uninstall.',
);
}
}
/// Mirrors the IN-PROGRESS session (or clears it when [data] is null).
///
/// Best-effort like [export]. Called on every set/rep change, so a wipe of
/// app-private storage — an uninstall, or `pm clear` — no longer costs the
/// user the workout they are standing in the middle of.
Future<void> exportActiveSession(Map<String, dynamic>? data) async {
try {
final f = File(_activeSessionPath);
if (data == null) {
if (f.existsSync()) await f.delete();
return;
}
final dir = Directory(_baseDir);
if (!dir.existsSync()) {
dir.createSync(recursive: true);
}
await f.writeAsString(jsonEncode(data));
} on Exception catch (error) {
// Backup is best-effort; never throw mid-workout.
debugPrint(
'WorkoutApp: could not mirror the active session to '
'$_activeSessionPath ($error) — an app-data wipe mid-workout would '
'now lose the set you are standing on.',
);
}
}
/// Returns the mirrored in-progress session, or null if none / unreadable.
Future<Map<String, dynamic>?> readActiveSession() async {
try {
final f = File(_activeSessionPath);
if (!f.existsSync()) return null;
final decoded = jsonDecode(await f.readAsString());
return decoded is Map<String, dynamic> ? decoded : null;
} on Exception catch (error) {
debugPrint(
'WorkoutApp: mirrored active session at $_activeSessionPath exists '
'but is unreadable ($error) — an in-progress workout cannot be '
'resumed from it.',
);
return null;
}
}
/// Returns the mirrored sync token, or null if none exists / unreadable.
Future<String?> readSyncToken() async {
try {
final f = File(_syncTokenPath);
if (!f.existsSync()) return null;
final token = await f.readAsString();
return token.isEmpty ? null : token;
// coverage:ignore-start
// Defensive: a present, readable token file is the norm; a portable way
// to make readAsString throw here (unreadable file) isn't available.
} on Exception catch (error) {
debugPrint(
'WorkoutApp: mirrored sync token at $_syncTokenPath exists but is '
'unreadable ($error) — sync cannot be recovered from the backup.',
);
return null;
}
// coverage:ignore-end
}
}

View File

@ -0,0 +1,97 @@
// The active-session mirror and the sync-token file.
//
// An `extension` in a `part`: Dart cannot continue a class body across
// files, but library privacy still applies inside a part, so these reach
// _activeSessionPath / _syncTokenPath and callers are unchanged.
part of 'backup_service.dart';
/// The active-session mirror and sync-token file on external storage.
extension BackupServiceSession on BackupService {
/// Mirrors [token] to external storage (deleting the file when empty).
/// Best-effort like [export]; see the library doc for the security
/// trade-off this represents.
Future<void> exportSyncToken(String token) async {
try {
final f = File(_syncTokenPath);
if (token.isEmpty) {
if (f.existsSync()) await f.delete();
return;
}
final dir = Directory(BackupService._baseDir);
if (!dir.existsSync()) {
dir.createSync(recursive: true);
}
await f.writeAsString(token);
} on Exception catch (error) {
// Backup is best-effort; never throw.
debugPrint(
'WorkoutApp: could not mirror the sync token to $_syncTokenPath '
'($error) — the token will NOT survive the next uninstall.',
);
}
}
/// Mirrors the IN-PROGRESS session (or clears it when [data] is null).
///
/// Best-effort like [export]. Called on every set/rep change, so a wipe of
/// app-private storage — an uninstall, or `pm clear` — no longer costs the
/// user the workout they are standing in the middle of.
Future<void> exportActiveSession(Map<String, dynamic>? data) async {
try {
final f = File(_activeSessionPath);
if (data == null) {
if (f.existsSync()) await f.delete();
return;
}
final dir = Directory(BackupService._baseDir);
if (!dir.existsSync()) {
dir.createSync(recursive: true);
}
await f.writeAsString(jsonEncode(data));
} on Exception catch (error) {
// Backup is best-effort; never throw mid-workout.
debugPrint(
'WorkoutApp: could not mirror the active session to '
'$_activeSessionPath ($error) — an app-data wipe mid-workout would '
'now lose the set you are standing on.',
);
}
}
/// Returns the mirrored in-progress session, or null if none / unreadable.
Future<Map<String, dynamic>?> readActiveSession() async {
try {
final f = File(_activeSessionPath);
if (!f.existsSync()) return null;
final decoded = jsonDecode(await f.readAsString());
return decoded is Map<String, dynamic> ? decoded : null;
} on Exception catch (error) {
debugPrint(
'WorkoutApp: mirrored active session at $_activeSessionPath exists '
'but is unreadable ($error) — an in-progress workout cannot be '
'resumed from it.',
);
return null;
}
}
/// Returns the mirrored sync token, or null if none exists / unreadable.
Future<String?> readSyncToken() async {
try {
final f = File(_syncTokenPath);
if (!f.existsSync()) return null;
final token = await f.readAsString();
return token.isEmpty ? null : token;
// coverage:ignore-start
// Defensive: a present, readable token file is the norm; a portable way
// to make readAsString throw here (unreadable file) isn't available.
} on Exception catch (error) {
debugPrint(
'WorkoutApp: mirrored sync token at $_syncTokenPath exists but is '
'unreadable ($error) — sync cannot be recovered from the backup.',
);
return null;
}
// coverage:ignore-end
}
}

View File

@ -14,6 +14,8 @@ import 'package:workout_app/services/sync_device_id.dart';
import 'package:workout_app/services/sync_settings.dart';
import 'package:workout_app/services/sync_state_factory.dart';
part 'workout_sync_service_read.dart';
const _pathPrefix = 'screen-locker-sync/devices';
const _logFilename = 'log.json';
@ -174,17 +176,6 @@ class WorkoutSyncService {
}
}
/// Whether this device has ANY sync credentials -- a GitHub token or a
/// Firebase account.
///
/// Mirrors the gate every push already applies internally, exposed so the
/// home screen can tell "not set up" (the user must act) apart from "set
/// up but broken" (retrying might help) without duplicating the rule.
Future<bool> isConfigured() async {
final settings = await SyncSettings.load();
return settings.isConfigured || await _hasFirebaseAccount();
}
/// Runs a sync tick with no new record, purely to find out whether sync
/// works right now.
///
@ -310,7 +301,6 @@ class WorkoutSyncService {
/// Returns an empty list if sync isn't configured or the repo is unreachable.
Future<List<Map<String, dynamic>>> readMergedManualPayloads() =>
_readMergedPayloads(kind: kManualWorkoutSyncKind);
/// Every synced workout, whatever kind — manual, StrongLifts or RunnerUp.
///
/// The PC publishes its whole `workout_log.json` (including verified runs),
@ -319,97 +309,14 @@ class WorkoutSyncService {
/// uses [readMergedManualPayloads] instead.
Future<List<Map<String, dynamic>>> readMergedWorkoutPayloads() =>
_readMergedPayloads();
Future<List<Map<String, dynamic>>> _readMergedPayloads({String? kind}) async {
final settings = await SyncSettings.load();
if (!settings.isConfigured && !await _hasFirebaseAccount()) {
debugPrint(
'WorkoutSyncService: NOT reading synced workouts — no sync backend '
'configured in Settings, so this phone cannot see the PC history.',
);
return const [];
}
try {
return await _fetchPayloads(settings.token, kind);
} on GitHubSyncError catch (error) {
var failure = error;
// A stale keystore token shadows a good backup (SyncSettings.load only
// falls back when the keystore is EMPTY), so retry once from the backup
// rather than leaving history silently incomplete.
final recovered = await SyncSettings.recoverFromBackup(settings.token);
if (recovered != null) {
try {
final payloads = await _fetchPayloads(recovered, kind);
debugPrint(
'WorkoutSyncService: recovered the sync token from backup after '
'the stored one was rejected ($failure).',
);
return payloads;
} on GitHubSyncError catch (retryError) {
failure = retryError;
}
}
final which = kind ?? 'any';
debugPrint(
'WorkoutSyncService: FAILED reading synced workouts '
'(kind=$which) from $owner/$repo: $failure — history may be incomplete.',
);
return const [];
}
}
Future<List<Map<String, dynamic>>> _fetchPayloads(
String token,
String? kind,
) async {
final github = GitHubClient(
owner: owner,
repo: repo,
token: token,
httpClient: _httpClient,
);
// Read-only: MirrorStore reads the union of both, so a workout logged
// against either backend still shows up during the cutover.
final firebase = await _openFirebase();
final client = firebase == null
? github
: MirrorStore(primary: firebase, mirror: github);
try {
final merged = <String, Record>{};
for (final device in await client.listDirectory(_pathPrefix)) {
final text = await client.getFileText(
'$_pathPrefix/$device/$_logFilename',
);
if (text == null) continue;
_mergeRecords(_decode(text), merged, kind: kind);
}
return merged.values
.map((r) => (r.fields['payload']!.$1! as Map).cast<String, dynamic>())
.toList();
} finally {
github.close();
firebase?.close();
}
}
/// Merges records into [into], keeping the highest-HLC copy of each id.
/// Whether this device has ANY sync credentials -- a GitHub token or a
/// Firebase account.
///
/// [kind] filters on the payload's `kind` discriminator; pass null to keep
/// every workout kind. The manual-workout budget must only ever see manual
/// self-reports (a verified run must not consume that budget), while the
/// history view wants everything — hence the filter rather than two merges.
static void _mergeRecords(Log log, Map<String, Record> into, {String? kind}) {
for (final entry in log.entries) {
final field = entry.value.fields['payload'];
if (field == null) continue;
final payload = field.$1;
if (payload is! Map) continue;
if (kind != null && payload['kind'] != kind) continue;
final existing = into[entry.key];
if (existing == null || existing.fields['payload']!.$2 < field.$2) {
into[entry.key] = entry.value;
}
}
/// Mirrors the gate every push already applies internally, exposed so the
/// home screen can tell "not set up" (the user must act) apart from "set
/// up but broken" (retrying might help) without duplicating the rule.
Future<bool> isConfigured() async {
final settings = await SyncSettings.load();
return settings.isConfigured || await _hasFirebaseAccount();
}
}

View File

@ -0,0 +1,103 @@
// Reading and merging payloads back out of the sync backends.
//
// An `extension` in a `part` -- see storage_service_backup.dart for why.
part of 'workout_sync_service.dart';
/// Reads merged workout/manual payloads across every device log.
extension WorkoutSyncServiceRead on WorkoutSyncService {
Future<List<Map<String, dynamic>>> _readMergedPayloads({String? kind}) async {
final settings = await SyncSettings.load();
if (!settings.isConfigured && !await _hasFirebaseAccount()) {
debugPrint(
'WorkoutSyncService: NOT reading synced workouts — no sync backend '
'configured in Settings, so this phone cannot see the PC history.',
);
return const [];
}
try {
return await _fetchPayloads(settings.token, kind);
} on GitHubSyncError catch (error) {
var failure = error;
// A stale keystore token shadows a good backup (SyncSettings.load only
// falls back when the keystore is EMPTY), so retry once from the backup
// rather than leaving history silently incomplete.
final recovered = await SyncSettings.recoverFromBackup(settings.token);
if (recovered != null) {
try {
final payloads = await _fetchPayloads(recovered, kind);
debugPrint(
'WorkoutSyncService: recovered the sync token from backup after '
'the stored one was rejected ($failure).',
);
return payloads;
} on GitHubSyncError catch (retryError) {
failure = retryError;
}
}
final which = kind ?? 'any';
debugPrint(
'WorkoutSyncService: FAILED reading synced workouts '
'(kind=$which) from $owner/$repo: $failure — history may be incomplete.',
);
return const [];
}
}
Future<List<Map<String, dynamic>>> _fetchPayloads(
String token,
String? kind,
) async {
final github = GitHubClient(
owner: owner,
repo: repo,
token: token,
httpClient: _httpClient,
);
// Read-only: MirrorStore reads the union of both, so a workout logged
// against either backend still shows up during the cutover.
final firebase = await _openFirebase();
final client = firebase == null
? github
: MirrorStore(primary: firebase, mirror: github);
try {
final merged = <String, Record>{};
for (final device in await client.listDirectory(_pathPrefix)) {
final text = await client.getFileText(
'$_pathPrefix/$device/$_logFilename',
);
if (text == null) continue;
_mergeRecords(_decode(text), merged, kind: kind);
}
return merged.values
.map((r) => (r.fields['payload']!.$1! as Map).cast<String, dynamic>())
.toList();
} finally {
github.close();
firebase?.close();
}
}
/// Merges records into [into], keeping the highest-HLC copy of each id.
///
/// [kind] filters on the payload's `kind` discriminator; pass null to keep
/// every workout kind. The manual-workout budget must only ever see manual
/// self-reports (a verified run must not consume that budget), while the
/// history view wants everything — hence the filter rather than two merges.
static void _mergeRecords(Log log, Map<String, Record> into, {String? kind}) {
for (final entry in log.entries) {
final field = entry.value.fields['payload'];
if (field == null) continue;
final payload = field.$1;
if (payload is! Map) continue;
if (kind != null && payload['kind'] != kind) continue;
final existing = into[entry.key];
if (existing == null || existing.fields['payload']!.$2 < field.$2) {
into[entry.key] = entry.value;
}
}
}
}