screen-locker/stronglift_replacement/workout_app/lib/services/backup_service.dart
Krzysztof kuhy Rudnicki 83d77884a0 Workout screen never moves when a rest starts; a sandbox flavor to prove it on the phone
The rest banner used to be inserted into the column only while a break ran,
so its arrival took height from the exercise list, shifted every tile down
and pushed the last one into scroll range. The strip is now a permanently
reserved 64 dp slot (muted "Rest / 00:00" while idle, Skip keeps its space),
the list is a non-scrolling column that scales down as a whole if it would
not fit (0.96 on the Pixel 6a with four tiles, pinned by a widget test at
the phone's logical size with the status bar simulated), and the activity is
locked to portrait. Verified on the real phone: only the tapped circle's
pixels change when a rest starts.

Verifying that needed a workout screen that today's already-logged workout
did not offer, so the app gained a `sandbox` product flavor beside `daily`:
com.kuhy.workout_app.sandbox installs side by side with its own data, refuses
every network connection at the socket layer, starts no LAN server for the
PC to pull from, keeps its /sdcard files beside the daily build's, wears a
red SANDBOX ribbon, and offers a SANDBOX section in Settings (wipe, mark
today not done, rest length in seconds). MainActivity answers the flavor and
carries a `WorkoutSandbox` logcat trace of every tap, break, save and
navigation. On Linux the same mode is WORKOUT_SANDBOX=1, driven under Xvfb by
scripts/run_sandbox_xvfb.sh. CI builds --flavor daily; .phone-deploy-flavor
tells phone_deploy.sh the same.

Set circles and the home icon buttons carry semantics labels so they can be
addressed by element (and by screen readers).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YbBZqUaNWyhHYfzz6zGC7q
2026-09-20 18:55:08 +02:00

167 lines
7.3 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;
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: tests point it at a real temp directory instead of
// `/sdcard`, which doesn't exist on the test host, and the sandbox flavor
// at its own `/sdcard/WorkoutTrackerSandbox` beside the daily build's.
/// The directory backup files are written to and read from.
static String baseDir = kBackupDir;
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 ────────────────────────────────────────────────────
}