mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-12 08:30:54 +01:00
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
176 lines
7.6 KiB
Dart
176 lines
7.6 KiB
Dart
/// Backup service: mirrors the DB (and, deliberately, the GitHub sync token)
|
|
/// as files on external storage so they survive uninstall/reinstall on
|
|
/// Android 11+, where internal storage -- including the Keystore-backed
|
|
/// secure storage the token normally lives in -- is wiped.
|
|
///
|
|
/// Mirroring the token here is a deliberate, explicit trade-off: it moves
|
|
/// the token from Keystore-only secrecy to plaintext-on-external-storage,
|
|
/// which is readable by any app holding storage permission or by plugging
|
|
/// the phone into a PC. That's a real credential-exposure increase over
|
|
/// diet-guard/todo (which keep their tokens Keystore-only and do not survive
|
|
/// reinstall) -- accepted here because losing sync on every reinstall during
|
|
/// active development was worse in practice than the exposure risk.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
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';
|
|
|
|
/// Path where the backup JSON lives on external storage.
|
|
const String kBackupPath = '$kBackupDir/backup.json';
|
|
|
|
/// Path where the GitHub sync token is mirrored on external storage.
|
|
const String kSyncTokenBackupPath = '$kBackupDir/sync_token';
|
|
|
|
/// Path where the IN-PROGRESS session is mirrored on external storage.
|
|
///
|
|
/// The active session (which set you are on, and the reps done so far) lives
|
|
/// in app-private SQLite, which an uninstall or `pm clear` wipes — losing the
|
|
/// workout you are standing in the middle of. Mirroring it here means even a
|
|
/// full app-data wipe resumes on the exact set and rep.
|
|
const String kActiveSessionBackupPath = '$kBackupDir/active_session.json';
|
|
|
|
/// Handles exporting and importing the workout database (and the GitHub
|
|
/// sync token) as files on external storage, which persist across app
|
|
/// uninstalls.
|
|
class BackupService {
|
|
BackupService._();
|
|
|
|
/// The singleton instance.
|
|
static final BackupService instance = BackupService._();
|
|
|
|
// Overridable for unit tests (set by resetForTesting) so round-trips can
|
|
// be verified against a real temp directory instead of `/sdcard`, which
|
|
// doesn't exist on the test host.
|
|
static String _baseDir = kBackupDir;
|
|
|
|
/// The directory backup files are currently written to/read from.
|
|
/// Test-only.
|
|
@visibleForTesting
|
|
static String get baseDirForTesting => _baseDir;
|
|
|
|
/// Points the backup files at this directory instead of [kBackupDir].
|
|
/// Test-only.
|
|
@visibleForTesting
|
|
static set baseDirForTesting(String dir) => _baseDir = dir;
|
|
|
|
String get _backupPath => '$_baseDir/backup.json';
|
|
String get _syncTokenPath => '$_baseDir/sync_token';
|
|
|
|
String get _activeSessionPath => '$_baseDir/active_session.json';
|
|
|
|
// ── Permission ─────────────────────────────────────────────────────────────
|
|
|
|
// coverage:ignore-start
|
|
// Thin wrappers over the permission_handler platform channel — Android
|
|
// runtime permissions that can't be meaningfully exercised on the test host.
|
|
|
|
/// Whether MANAGE_EXTERNAL_STORAGE is already held.
|
|
///
|
|
/// A pure query: unlike [requestStoragePermission] this never navigates the
|
|
/// user anywhere, so startup can check it and move on.
|
|
///
|
|
/// Answers "not granted" instead of throwing when there is no platform
|
|
/// channel to ask — the state on the `flutter test` host, where a
|
|
/// MissingPluginException from a widget's `initState` fails the test rather
|
|
/// than surfacing anything useful. "No permission" is also the truthful
|
|
/// answer there: the test host has no `/sdcard` to write to.
|
|
Future<bool> hasStoragePermission() async {
|
|
try {
|
|
return await Permission.manageExternalStorage.isGranted;
|
|
} on MissingPluginException catch (error) {
|
|
debugPrint(
|
|
'WorkoutApp: no permission_handler platform channel ($error) — '
|
|
'treating storage as not granted. Expected on the test host.',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Requests MANAGE_EXTERNAL_STORAGE; opens the system settings page.
|
|
///
|
|
/// Only ever called from an explicit user action in Settings. Startup must
|
|
/// NOT call this: `Permission.manageExternalStorage.request()` throws the
|
|
/// user into a system Settings page whenever the permission is absent, and
|
|
/// since Firebase carries progression the `/sdcard` backup is a convenience,
|
|
/// not a requirement. Prompting for it on every launch made an optional
|
|
/// permission feel mandatory.
|
|
///
|
|
/// Returns true once granted.
|
|
Future<bool> requestStoragePermission() async {
|
|
final status = await Permission.manageExternalStorage.request();
|
|
return status.isGranted;
|
|
}
|
|
// coverage:ignore-end
|
|
|
|
// ── Export ─────────────────────────────────────────────────────────────────
|
|
|
|
/// Writes [data] to the backup path as pretty-printed JSON.
|
|
///
|
|
/// Never throws — a failed backup must not crash a workout in progress — but
|
|
/// it is NOT silent: a backup that quietly stops working is invisible until
|
|
/// the reinstall that needed it, which is exactly how the 2026-08-05 wipe
|
|
/// destroyed months of progression.
|
|
///
|
|
/// Returns true if the backup was written.
|
|
Future<bool> export(Map<String, dynamic> data) async {
|
|
try {
|
|
final dir = Directory(_baseDir);
|
|
if (!dir.existsSync()) {
|
|
dir.createSync(recursive: true);
|
|
}
|
|
await File(
|
|
_backupPath,
|
|
).writeAsString(const JsonEncoder.withIndent(' ').convert(data));
|
|
return true;
|
|
} on Exception catch (e) {
|
|
debugPrint(
|
|
'WorkoutApp: BACKUP FAILED to $_backupPath: $e — progression state is '
|
|
'now unprotected against an uninstall/reinstall. Check that storage '
|
|
'permission is granted.',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── Import ─────────────────────────────────────────────────────────────────
|
|
|
|
/// Returns the parsed backup JSON, or null if the file does not exist or
|
|
/// is unreadable.
|
|
///
|
|
/// Distinguishes the two cases in the log: "no backup exists" is normal on a
|
|
/// genuinely first install, whereas "a backup exists but could not be read"
|
|
/// means a restore is about to fall through to defaults and silently lose
|
|
/// real data. The second must never pass unnoticed.
|
|
Future<Map<String, dynamic>?> readBackup() async {
|
|
final f = File(_backupPath);
|
|
try {
|
|
if (!f.existsSync()) {
|
|
debugPrint(
|
|
'WorkoutApp: no backup at $_backupPath — nothing to restore. Normal '
|
|
'on a first install; on a REINSTALL it means the backup was lost.',
|
|
);
|
|
return null;
|
|
}
|
|
final raw = await f.readAsString();
|
|
return jsonDecode(raw) as Map<String, dynamic>;
|
|
} on Exception catch (e) {
|
|
debugPrint(
|
|
'WorkoutApp: BACKUP UNREADABLE at $_backupPath: $e — a backup EXISTS '
|
|
'but cannot be parsed, so restore will fall through to defaults and '
|
|
'lose progression state. Do not let the app overwrite it.',
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Sync token backup ────────────────────────────────────────────────────
|
|
}
|