mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
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
167 lines
7.3 KiB
Dart
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 ────────────────────────────────────────────────────
|
|
}
|