todo-app/lib/sync/sync_settings.dart
Krzysztof kuhy Rudnicki f463e50d67 Switch to very_good_analysis, matching the rest of the Flutter ecosystem
Standardizes on the same stricter lint set + shared analysis_options.yaml
used by diet-guard/app and the new habit_stack app. Fixes all 127 issues
surfaced: dart fix --apply for imports/cascades/sorting, unawaited() on
fire-and-forget Futures, bare catch(e) narrowed to on Exception catch(e)
(matching diet_guard's existing pattern), one documented ignore for a
deliberate TypeError catch around untrusted peer JSON, and missing
public-member doc comments throughout. No behavior change: 174/174 tests
pass, 100% line coverage maintained.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 11:05:13 +02:00

149 lines
5.4 KiB
Dart

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Locally-stored GitHub sync configuration.
///
/// The GitHub token is kept in the OS keystore (Android Keystore / libsecret)
/// via [flutter_secure_storage]; only the non-secret owner/repo/clientId live
/// in `SharedPreferences`. Older builds stored the token in plaintext prefs;
/// [load]/[save] migrate it transparently and never drop the plaintext copy
/// until a secure write is confirmed (so we degrade to — never below — the old
/// behaviour when no secret service is available).
class SyncSettings {
/// Creates a [SyncSettings] from its stored fields.
const SyncSettings({
required this.owner,
required this.repo,
required this.token,
this.clientId = '',
});
/// GitHub account or org that owns [repo].
final String owner;
/// GitHub repository name notes sync to.
final String repo;
/// GitHub token used to read/write [repo] (kept in the OS keystore).
final String token;
/// GitHub OAuth App client id used by the device-flow "Connect" button.
/// Not a secret (device flow needs no client secret), so it is safe to ship
/// as a compile-time default and commit to source — see [defaultClientId].
final String clientId;
/// The app's own GitHub OAuth App (device-flow enabled) client id, baked in
/// so "Connect GitHub" works with zero setup — even after a reinstall. A
/// device-flow client id is a public identifier, not a secret.
static const defaultClientId = 'Ov23li9tF2R46PqzJgch';
/// True when enough is set to attempt a sync.
bool get isConfigured =>
owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty;
/// True when device-flow "Connect GitHub" can be offered.
bool get canUseDeviceFlow => clientId.isNotEmpty;
static const _kOwner = 'sync.owner';
static const _kRepo = 'sync.repo';
// Legacy plaintext location for the token; read-only now and removed once the
// token has been migrated into secure storage.
static const _kToken = 'sync.token';
static const _kClientId = 'sync.clientId';
/// Key for the token inside the OS keystore.
static const _secureToken = 'sync.token';
/// Default options keep us off the deprecated `encryptedSharedPreferences`
/// path on Android and use libsecret on Linux.
static const _secure = FlutterSecureStorage();
/// Loads settings, defaulting the repo to `kuhyx/syncs` and the client id
/// to the baked-in [defaultClientId] so first run (and any reinstall) needs
/// nothing but a single "Connect GitHub" tap.
///
/// A device that already persisted the old standalone `todo-sync` repo
/// name (before it was folded into the shared `syncs` monorepo) is
/// migrated to `syncs` here and the corrected value is written straight
/// back to prefs, so this only ever runs once per device.
static Future<SyncSettings> load() async {
final prefs = await SharedPreferences.getInstance();
var repo = prefs.getString(_kRepo) ?? 'syncs';
if (repo == 'todo-sync') {
repo = 'syncs';
await prefs.setString(_kRepo, repo);
}
return SyncSettings(
owner: prefs.getString(_kOwner) ?? 'kuhyx',
repo: repo,
token: await _loadToken(prefs),
clientId: prefs.getString(_kClientId) ?? defaultClientId,
);
}
/// Reads the token, preferring the keystore and falling back to the legacy
/// plaintext value. A legacy value is migrated into the keystore on read, but
/// only dropped from prefs once that secure write succeeds.
static Future<String> _loadToken(SharedPreferences prefs) async {
String? secure;
try {
secure = await _secure.read(key: _secureToken);
} on Exception {
// No secret service available — fall back to the legacy plaintext copy.
secure = null;
}
if (secure != null && secure.isNotEmpty) return secure;
final legacy = prefs.getString(_kToken) ?? '';
if (legacy.isNotEmpty && await _writeSecureToken(legacy)) {
await prefs.remove(_kToken);
}
return legacy;
}
/// Persists these settings, writing [token] to the OS keystore.
Future<void> save() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kOwner, owner);
await prefs.setString(_kRepo, repo);
await prefs.setString(_kClientId, clientId);
// Confirm-before-delete: only remove the plaintext copy once the keystore
// write succeeds; otherwise keep persisting it to prefs as before.
if (await _writeSecureToken(token)) {
await prefs.remove(_kToken);
} else {
await prefs.setString(_kToken, token);
}
}
/// Writes [token] to the keystore (deleting the entry when empty). Returns
/// false if the platform secret service is unavailable.
static Future<bool> _writeSecureToken(String token) async {
try {
if (token.isEmpty) {
await _secure.delete(key: _secureToken);
} else {
await _secure.write(key: _secureToken, value: token);
}
return true;
} on Exception {
return false;
}
}
/// Returns a copy with the given fields replaced, others unchanged.
SyncSettings copyWith({
String? owner,
String? repo,
String? token,
String? clientId,
}) {
return SyncSettings(
owner: owner ?? this.owner,
repo: repo ?? this.repo,
token: token ?? this.token,
clientId: clientId ?? this.clientId,
);
}
}