mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
A real StrongLifts session on 2026-08-10 never reached this machine, so it had to be logged as a manual workout -- spending a manual budget slot on a workout that was genuinely verified. The session was in Firebase the whole time; four separate defects each hid it. - pull_synced_workout() read a hardcoded devices/phone/log.json. Sinceffc6c72the phone pushes under a per-install uuid, so that directory has been frozen since 2026-07-17 and the PC kept returning a stale session from 07-27 as if it were current. Both readers now walk EVERY device directory; reinstalls keep minting new ones, so no single path can be right for long. - A session is identified by SHAPE (it has `exercises`), not by a `kind` field the phone never writes. That filter is why the one reader that did walk every directory discarded real sessions, and it also kept PC-origin runnerup/phone_verified records from being handed to a caller expecting a session. - FirebaseSyncError is a SIBLING of GitHubSyncError, not a subclass, so `except GitHubSyncError` never caught it and a DNS failure took the whole locker down (status=1/FAILURE, then a restart loop). Catch the common parent; a pull failure degrades to a warning. - A GitHub token is no longer required to sync. Firebase has been primary sinceeb4ff01, so a Firebase-only machine was reporting "sync is OFF" while syncing fine. Sync also only ran once per locker start, so a workout finished after login was invisible until the next login. workout-sync.timer runs the new headless --sync-only every 15 minutes; remove_systemd.sh now removes every unit the installer creates, not just two of them. workout_app: the 2026-08-05 reinstall wiped progression because the DB seeds factory defaults before the backup is restored, and the first write then exported those defaults over the only off-device copy. Storage permission is now settled before the DB opens, a defaults-only DB refuses to overwrite a richer backup, and the three swallowed storage errors say what broke. Sessions that exist only in Firebase/GitHub are restored into local history, so a reinstalled app has the history it actually earned rather than starting at whatever survived. Matched on (date, start_time), so re-syncing never duplicates and local rows always win. Adds a target-reps editor: progression can only ever RAISE reps, so a rep target a defaults re-seed set wrong could not be corrected from the UI at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
219 lines
8.6 KiB
Dart
219 lines
8.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:permission_handler/permission_handler.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.
|
|
|
|
/// Returns true if the app has MANAGE_EXTERNAL_STORAGE.
|
|
Future<bool> hasStoragePermission() async {
|
|
return Permission.manageExternalStorage.isGranted;
|
|
}
|
|
|
|
/// Requests MANAGE_EXTERNAL_STORAGE; opens the system settings page.
|
|
///
|
|
/// 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 ────────────────────────────────────────────────────
|
|
|
|
/// 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 {
|
|
// Backup is best-effort; never throw.
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
// Backup is best-effort; never throw mid-workout.
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
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 {
|
|
return null;
|
|
}
|
|
// coverage:ignore-end
|
|
}
|
|
}
|