mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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:
parent
3d992ef71b
commit
9184a6bc33
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user