screen-locker/stronglift_replacement/workout_app/lib/services/sync_settings.dart
Krzysztof kuhy Rudnicki b026dfd074 Recover the sync token from backup when the stored one is rejected
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
2026-07-17 09:45:12 +02:00

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;
}
}
}