mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 16:33:57 +01:00
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Progression and the in-progress session now live at
screen-locker-sync/exercise_state/<name> and .../active_session — siblings of
devices/, not inside it. Both workout readers (_workout_sync.py:113 and
workout_sync_service.dart:307) list only screen-locker-sync/devices, so a
progression record is structurally invisible to them and cannot be miscounted
as a workout, which would silently grant unlock credit. Keyed on exercise name
because a reinstall mints a new per-install uuid.
Startup no longer calls requestStoragePermission(): that threw the user into a
system Settings page on every launch, for a permission that is now genuinely
optional. It only checks; the grant moved to an OFFLINE BACKUP section in
Settings, its sole caller.
The clobber guard keys on a persisted progression_synced_at marker, NOT
looksFreshlyInstalled(). The only production caller (_finishWorkout) writes a
workout_history row and last_workout_type BEFORE it pushes, so a freshness test
was already false by then and could never fire — a reinstall that connected
Firebase late (the uninstall wipes the keystore, so startup's pull finds no
account) would have overwritten every real remote record with factory defaults
on its first finished workout. Connecting in Settings now pulls immediately,
which closes that window. The same marker stops a pull reverting a deliberate
setExerciseWeight/resetExerciseToDefaults, which write no history at all.
Also adds a Dart silent-failure pre-commit hook. The Python one is
types:[python] and had never looked at a single Dart file — in the app where a
swallowed sync error costs the user a workout with nothing anywhere saying so.
It found 21 real silent swallows (keystore reads/writes, /sdcard mirror
failures, the HTTP bind, device-flow polls, and a .catchError((_) {})), all now
logging why.
Verified on the Pixel 6a: reinstall + deny storage + connect late restored
Bench 22.5→20.0, Row 22.5→25.0, Situp 30→31 reps with the remote copy
untouched; the RTDB device list stayed at exactly 3 entries and no progression
leaked into pull_synced_workout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JupKvZCNKRmEeRQY3G1unG
134 lines
5.6 KiB
Dart
134 lines
5.6 KiB
Dart
/// Locally-stored GitHub sync token, trimmed from diet-guard's
|
|
/// `sync_settings.dart`: this app only ever talks to one hardcoded repo, so
|
|
/// there's no owner/repo to persist -- just the pasted PAT (kept as a
|
|
/// fallback; see [defaultClientId] for the primary "Connect GitHub" path).
|
|
///
|
|
/// Unlike diet-guard/todo, the token is also mirrored to external storage
|
|
/// via [BackupService] so it survives an app uninstall -- see that class's
|
|
/// library doc for the security trade-off this represents.
|
|
library;
|
|
|
|
import 'package:flutter/foundation.dart' show debugPrint;
|
|
import 'package:flutter/services.dart' show PlatformException;
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:workout_app/services/backup_service.dart';
|
|
|
|
/// The repo owner/org, matching the PC's `SYNC_REPO_OWNER` constant.
|
|
const syncRepoOwner = 'kuhyx';
|
|
|
|
/// The repo name, matching the PC's `SYNC_REPO_NAME` constant.
|
|
const syncRepoName = 'syncs';
|
|
|
|
/// The GitHub token is kept in the OS keystore (Android Keystore) via
|
|
/// [FlutterSecureStorage] -- it never touches `SharedPreferences`, so there's
|
|
/// no plaintext-migration path to carry (unlike diet-guard/todo, which
|
|
/// predate this app and had an existing plaintext copy to migrate away from).
|
|
class SyncSettings {
|
|
/// Creates a [SyncSettings] from its token.
|
|
const SyncSettings({required this.token});
|
|
|
|
/// A GitHub PAT with contents read/write on `$syncRepoOwner/$syncRepoName`.
|
|
final String token;
|
|
|
|
/// True when a token has been pasted and sync can be attempted.
|
|
bool get isConfigured => token.isNotEmpty;
|
|
|
|
/// This app's own GitHub OAuth App (device-flow enabled) client id, baked
|
|
/// in so "Connect GitHub" works with zero setup. Registered 2026-07-06 at
|
|
/// github.com/settings/developers, device flow enabled -- distinct from
|
|
/// the sibling diet-guard/todo apps' OAuth Apps, which belong to different
|
|
/// products and can't be shared across them.
|
|
static const defaultClientId = 'Ov23liJNyu82dkIUiXSZ';
|
|
|
|
static const _secureToken = 'sync.token';
|
|
|
|
/// Default options keep us off the deprecated `encryptedSharedPreferences`
|
|
/// path on Android.
|
|
static const _secure = FlutterSecureStorage();
|
|
|
|
/// Loads the token from the keystore, falling back to the external-storage
|
|
/// backup (and re-seeding the keystore from it) if the keystore is empty --
|
|
/// which is exactly what happens right after a reinstall, since the
|
|
/// keystore entry does not survive uninstall but the backup file does.
|
|
static Future<SyncSettings> load() async {
|
|
String? token;
|
|
try {
|
|
token = await _secure.read(key: _secureToken);
|
|
} on PlatformException catch (error) {
|
|
debugPrint(
|
|
'WorkoutApp: could not read the sync token from the keystore '
|
|
'($error) — falling back to the external backup below.',
|
|
);
|
|
token = null;
|
|
}
|
|
if (token == null || token.isEmpty) {
|
|
final backedUp = await BackupService.instance.readSyncToken();
|
|
if (backedUp != null && backedUp.isNotEmpty) {
|
|
token = backedUp;
|
|
try {
|
|
await _secure.write(key: _secureToken, value: backedUp);
|
|
} on PlatformException catch (error) {
|
|
// Keystore unavailable; still return the recovered token below.
|
|
debugPrint(
|
|
'WorkoutApp: recovered the sync token from the backup but could '
|
|
'NOT write it back to the keystore ($error) — sync works this '
|
|
'run, but every launch will keep re-reading the backup.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return SyncSettings(token: token ?? '');
|
|
}
|
|
|
|
/// Re-seeds the keystore from the external backup after its token was
|
|
/// REJECTED by GitHub, returning the recovered token (or null).
|
|
///
|
|
/// [load] prefers the keystore and only consults the backup when the
|
|
/// keystore is EMPTY. So a stale keystore entry — say, one left by an
|
|
/// earlier authorization — shadows a perfectly good backup indefinitely,
|
|
/// and the user is told to re-authorize for nothing. Callers that see a
|
|
/// token rejected call this and retry ONCE before demanding re-auth.
|
|
///
|
|
/// Returns null when there is nothing to recover: no backup, an empty one,
|
|
/// or one holding the very token that was just rejected.
|
|
static Future<String?> recoverFromBackup(String rejectedToken) async {
|
|
final backedUp = await BackupService.instance.readSyncToken();
|
|
if (backedUp == null || backedUp.isEmpty || backedUp == rejectedToken) {
|
|
return null;
|
|
}
|
|
try {
|
|
await _secure.write(key: _secureToken, value: backedUp);
|
|
} on PlatformException catch (error) {
|
|
// Keystore unavailable — the caller can still use the returned token.
|
|
debugPrint(
|
|
'WorkoutApp: could not re-seed the keystore with the recovered sync '
|
|
'token ($error) — this retry still works, but the stale entry will '
|
|
'keep shadowing the backup on future launches.',
|
|
);
|
|
}
|
|
return backedUp;
|
|
}
|
|
|
|
/// Persists [token] to the keystore (deleting the entry when empty) and
|
|
/// mirrors it to external storage so it survives an app uninstall.
|
|
/// Returns false if the platform secret service is unavailable.
|
|
Future<bool> save() async {
|
|
await BackupService.instance.exportSyncToken(token);
|
|
try {
|
|
if (token.isEmpty) {
|
|
await _secure.delete(key: _secureToken);
|
|
} else {
|
|
await _secure.write(key: _secureToken, value: token);
|
|
}
|
|
return true;
|
|
} on PlatformException catch (error) {
|
|
debugPrint(
|
|
'WorkoutApp: could not save the sync token to the keystore ($error) '
|
|
'— it is still mirrored to external storage, so sync survives, but '
|
|
'the keystore copy is missing.',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
}
|