mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 16:33:57 +01:00
SyncSettings.load() prefers the keystore and only consults the external
backup when the keystore is EMPTY. So a stale keystore entry — one left by
an earlier authorization — shadows a perfectly good backup indefinitely:
every sync 401s and the user is told to re-authorize for nothing. This is
not hypothetical; it is the most likely explanation for the phone's 401,
whose token turned out to be healthy (GET /user 200, scope repo) once read
from the backup.
- SyncSettings.recoverFromBackup(rejectedToken) re-seeds the keystore from
the backup and returns the recovered token, or null when there is nothing
to recover (no backup, empty, or the same token that was just rejected).
- settings_screen retries verification once via the backup before demanding
re-auth, and says so ("recovered the saved token from backup") instead of
nagging.
- workout_sync_service retries the read once from the backup, so history
self-heals without the user having to visit Settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
114 lines
4.6 KiB
Dart
114 lines
4.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/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 {
|
|
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 {
|
|
// Keystore unavailable; still return the recovered token below.
|
|
}
|
|
}
|
|
}
|
|
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 {
|
|
// Keystore unavailable — the caller can still use the returned token.
|
|
}
|
|
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 {
|
|
return false;
|
|
}
|
|
}
|
|
}
|