From 9184a6bc33dcc7e52c699388f213fcb802e0bedc Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 21:38:42 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- .../lib/services/backup_service.dart | 90 +------------- .../lib/services/backup_service_session.dart | 97 +++++++++++++++ .../lib/services/workout_sync_service.dart | 113 ++---------------- .../services/workout_sync_service_read.dart | 103 ++++++++++++++++ 4 files changed, 212 insertions(+), 191 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/services/backup_service_session.dart create mode 100644 stronglift_replacement/workout_app/lib/services/workout_sync_service_read.dart diff --git a/stronglift_replacement/workout_app/lib/services/backup_service.dart b/stronglift_replacement/workout_app/lib/services/backup_service.dart index f903f4d..d5024dd 100644 --- a/stronglift_replacement/workout_app/lib/services/backup_service.dart +++ b/stronglift_replacement/workout_app/lib/services/backup_service.dart @@ -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 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 exportActiveSession(Map? 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?> readActiveSession() async { - try { - final f = File(_activeSessionPath); - if (!f.existsSync()) return null; - final decoded = jsonDecode(await f.readAsString()); - return decoded is Map ? 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 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 - } } diff --git a/stronglift_replacement/workout_app/lib/services/backup_service_session.dart b/stronglift_replacement/workout_app/lib/services/backup_service_session.dart new file mode 100644 index 0000000..4e20a17 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/backup_service_session.dart @@ -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 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 exportActiveSession(Map? 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?> readActiveSession() async { + try { + final f = File(_activeSessionPath); + if (!f.existsSync()) return null; + final decoded = jsonDecode(await f.readAsString()); + return decoded is Map ? 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 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 + } +} diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart index 3d5ff43..0735735 100644 --- a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart @@ -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 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>> 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>> readMergedWorkoutPayloads() => _readMergedPayloads(); - - Future>> _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>> _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 = {}; - 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()) - .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 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 isConfigured() async { + final settings = await SyncSettings.load(); + return settings.isConfigured || await _hasFirebaseAccount(); } } diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_service_read.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service_read.dart new file mode 100644 index 0000000..6e5f0c3 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service_read.dart @@ -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>> _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>> _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 = {}; + 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()) + .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 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; + } + } + } + +}