Files
plezy/lib/services/base_shared_preferences_service.dart
T
edde746 66549e3a67 fix(prefs): route every credential read through the tolerant path
The wrong-type recovery only covered reads that went through a
BaseSharedPreferencesService instance. The three stores that hold
credentials read the shared cache directly, so a mistyped value there
still threw a raw TypeError or, for Seerr, was swallowed by a catch-all
and reported as "no session" — the registry documented protection it did
not actually provide.

readPreferenceTolerantly now takes the cache, so CredentialVault,
TrackerAccountStore and SeerrSessionStore get the same classification as
the settings layer. CredentialVault's post-write re-read moves outside
its catch: a wrong-typed value written by another isolate was swallowed
there, and the process then returned a key that never durably landed,
making every ciphertext written under it unreadable on the next launch.

Those stores are consulted long after startup, where a throw is an
unhandled provider error rather than a repair prompt, so SettingsService
initialization now walks the cached key set once and reads every
sensitive key. That puts the failure inside a fatal gate step while the
store is still open and a surgical single-key repair is possible.

The remaining direct reads in settings and storage are routed too; the
only ones left are the library-density dual-type migration, which probes
both types deliberately, and an untyped switch that is type-safe by
construction.
2026-07-31 21:45:32 +02:00

535 lines
23 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';
import '../utils/app_logger.dart';
import 'prefs_recovery.dart';
import 'sensitive_prefs.dart';
/// Base class for services that use SharedPreferences singleton pattern.
///
/// This class handles the boilerplate for singleton initialization and
/// SharedPreferences lifecycle management. Subclasses should:
/// 1. Create a private named constructor (e.g., SettingsService._())
/// 2. Implement their own getInstance() method that calls BaseSharedPreferencesService.initializeInstance()
/// 3. Optionally override onInit() for post-initialization setup
abstract class BaseSharedPreferencesService {
static final Map<Type, BaseSharedPreferencesService> _instances = {};
static final Map<Type, Future<BaseSharedPreferencesService>> _initializations = {};
static int _resetGeneration = 0;
// Single shared cache across all subclasses so writes from one service are
// visible to reads from another without per-instance cache divergence.
static Future<SharedPreferencesWithCache>? _cacheFuture;
static Future<SharedPreferencesWithCache> Function() _cacheLoader = _loadSharedCache;
late SharedPreferencesWithCache _cache;
BaseSharedPreferencesService();
SharedPreferencesWithCache get prefs => _cache;
/// Initialize the preferences instance.
///
/// This method handles:
/// - Singleton instance management
/// - One-time migration from the legacy SharedPreferences API to the
/// SharedPreferencesAsync-backed cache (idempotent across launches)
/// - Calling onInit() hook for subclass-specific setup
static Future<T> initializeInstance<T extends BaseSharedPreferencesService>(T Function() constructor) {
final initialized = _instances[T];
if (initialized != null) return Future<T>.value(initialized as T);
final inFlight = _initializations[T];
if (inFlight != null) return inFlight.then((instance) => instance as T);
final generation = _resetGeneration;
final initialization = () async {
final instance = constructor();
instance._cache = await sharedCache();
await instance.onInit();
if (generation != _resetGeneration) {
return initializeInstance<T>(constructor);
}
_instances[T] = instance;
return instance;
}();
_initializations[T] = initialization;
return initialization.whenComplete(() {
if (identical(_initializations[T], initialization)) {
_initializations.remove(T);
}
});
}
/// Shared preferences cache used app-wide. Runs the legacy → async
/// migration on first call; subsequent calls return the same future.
/// Use this from services that don't extend [BaseSharedPreferencesService].
static Future<SharedPreferencesWithCache> sharedCache() {
final cached = _cacheFuture;
if (cached != null) return cached;
late final Future<SharedPreferencesWithCache> loading;
loading = _cacheLoader().then(
(cache) => cache,
onError: (Object error, StackTrace stackTrace) {
// Do not poison every later startup with one transient plugin/storage
// failure. Identity keeps a superseding/reset load intact while all
// concurrent callers continue to share this attempt.
if (identical(_cacheFuture, loading)) _cacheFuture = null;
Error.throwWithStackTrace(error, stackTrace);
},
);
_cacheFuture = loading;
return loading;
}
static Future<SharedPreferencesWithCache> _loadSharedCache() async {
// Validate before the plugin reads anything: the desktop backends memoise
// the document they parse and never re-read it, so a store rejected only
// after the fact could not be repaired in-process (#1732).
await PrefsRecovery.assertStoreReadable();
try {
return await _openSharedCache();
} catch (error, stackTrace) {
if (!PrefsRecovery.isCorruptStoreError(error)) rethrow;
// The preflight accepted this document and the plugin still rejected it,
// so it has now cached something we cannot reason about. Repair can
// still quarantine the file, but the process has to restart afterwards.
appLogger.e('Preference store could not be parsed', error: error, stackTrace: stackTrace);
throw CorruptPreferenceStoreException(error, stackTrace, reopenSafe: false);
}
}
static Future<SharedPreferencesWithCache> _openSharedCache() async {
final legacy = await SharedPreferences.getInstance();
await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
legacySharedPreferencesInstance: legacy,
sharedPreferencesAsyncOptions: const SharedPreferencesOptions(),
migrationCompletedKey: 'plezy_legacy_prefs_migrated_v1',
);
return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions());
}
/// Quarantines an *unparseable* store, opens a fresh one and reseeds every
/// credential that could be salvaged.
///
/// Never call this without an explicit user decision: it resets settings,
/// and any credential that could not be salvaged is gone. The salvaged vault
/// key is written before this future completes, which is what makes the
/// reseed safe — `CredentialVault` memoises the first key it sees, so a
/// single read landing before the seed would generate a replacement and
/// permanently orphan every token stored in the database. Nothing can read
/// preferences until [sharedCache] resolves, so doing the work here closes
/// that window entirely.
///
/// Only valid for [CorruptPreferenceStoreException]. The desktop plugins
/// memoise the parsed document in a private `_cachedPreferences` map and
/// never re-read it without an explicit reload; that map is only empty here
/// because the parse threw before it could be populated. Use
/// [dropUnreadableCredential] for a store that parsed but holds one
/// unreadable value — quarantining that one would reopen onto the stale
/// in-memory map and write the bad value straight back.
static Future<PrefsRepairOutcome> repairCorruptStore({bool reopenSafe = true}) async {
final (:salvaged, :backupPath) = await PrefsRecovery.quarantine();
_resetGeneration++;
_initializations.clear();
_instances.clear();
if (!reopenSafe) {
// The plugin memoised the bad document before it threw, so reopening
// would hand that copy back and the first write would persist it over
// the repaired file. Write the salvage straight to disk for the next
// process instead, and leave this one's store closed.
//
// Nothing may write a preference before that restart or the plugin's
// stale map would overwrite the seed; the caller keeps the app on the
// failure screen precisely so nothing does.
final seeded = await PrefsRecovery.seedStore(salvaged);
appLogger.w('Preference store quarantined; a restart is required before it can be reopened');
return PrefsRepairOutcome(
backupPath: backupPath,
vaultKeySalvaged: seeded && salvaged.vaultKey != null,
sessionsSalvaged: seeded ? salvaged.sessions.length : 0,
sessionsLost: seeded ? salvaged.losses : salvaged.losses + salvaged.sessions.length,
requiresRestart: true,
);
}
_cacheFuture = null;
final repaired = _cacheLoader().then((cache) async {
final vaultKey = salvaged.vaultKey;
if (vaultKey != null) await cache.setString(credentialVaultKeyPref, vaultKey);
for (final entry in salvaged.sessions.entries) {
await cache.setString(entry.key, entry.value);
}
return cache;
});
_cacheFuture = repaired;
await repaired;
return PrefsRepairOutcome(
backupPath: backupPath,
vaultKeySalvaged: salvaged.vaultKey != null,
sessionsSalvaged: salvaged.sessions.length,
sessionsLost: salvaged.losses,
);
}
/// Removes one credential preference whose stored type is unreadable.
///
/// The store itself parsed here, so the plugin's `_cachedPreferences` map is
/// already populated and a quarantine-and-reopen would hand back that stale
/// map and persist the bad value again. Delete through the live cache
/// instead: that updates both the in-memory map and the file, and leaves
/// every other credential in place.
///
/// The file is *copied* first, not moved — it is still the app's live store,
/// and the copy is the only record of the pre-repair state.
static Future<PrefsRepairOutcome> dropUnreadableCredential(String key) async {
final backupPath = await PrefsRecovery.backupStore();
final cache = await sharedCache();
await cache.remove(key);
// Force `onInit` to run again against the repaired store; the cache future
// stays as-is because the store was never reopened.
_resetGeneration++;
_initializations.clear();
_instances.clear();
appLogger.w('Removed unreadable credential preference "$key"');
return PrefsRepairOutcome(
backupPath: backupPath,
// The vault key survives unless it was the unreadable value itself.
vaultKeySalvaged: key != credentialVaultKeyPref,
sessionsSalvaged: 0,
sessionsLost: key == credentialVaultKeyPref ? 0 : 1,
settingsReset: false,
);
}
@visibleForTesting
static void setCacheLoaderForTesting(Future<SharedPreferencesWithCache> Function() loader) {
_cacheFuture = null;
_cacheLoader = loader;
}
/// Drop all cached singleton instances and the shared cache future so the
/// next `getInstance()` call rebuilds against the current
/// `SharedPreferences.setMockInitialValues(...)`. Test-only.
@visibleForTesting
static void resetForTesting() {
_resetGeneration++;
_initializations.clear();
_instances.clear();
_cacheFuture = null;
_cacheLoader = _loadSharedCache;
}
/// Reads a stored value, tolerating one whose type no longer matches the
/// declaration.
///
/// `SharedPreferencesWithCache.getX` is an `as T?` cast, so a value written
/// by an older build, hand-edited, or partially recovered throws `TypeError`
/// rather than returning null. A value we cannot read is indistinguishable
/// from one that was never written, so drop the key and fall back to the
/// declared default instead of letting it propagate — before #1732 a single
/// mistyped preference could fail the entire startup gate.
///
/// Credential slots are exempt: silently dropping one would sign the user
/// out with no explanation. Those raise
/// [UnreadableSensitivePreferenceException], which the startup gate
/// classifies as repairable so the user gets the same consented repair as an
/// unparseable store.
T? _readTolerant<T>(String key, T? Function() read) => readPreferenceTolerantly(_cache, key, read);
/// Nullable reads routed through [readPreferenceTolerantly]. Use these
/// instead of `prefs.getX(...)` wherever a mistyped stored value must not
/// throw — which is everywhere except a call site that deliberately probes
/// two types to migrate between them.
String? readNullableString(String key) => readTolerantString(_cache, key);
bool? readNullableBool(String key) => _readTolerant(key, () => _cache.getBool(key));
int? readNullableInt(String key) => _readTolerant(key, () => _cache.getInt(key));
/// Typed read helpers — return the stored value or [defaultValue] when missing.
bool readBool(String key, {bool defaultValue = false}) =>
_readTolerant(key, () => _cache.getBool(key)) ?? defaultValue;
int readInt(String key, {int defaultValue = 0}) => _readTolerant(key, () => _cache.getInt(key)) ?? defaultValue;
double readDouble(String key, {double defaultValue = 0.0}) =>
_readTolerant(key, () => _cache.getDouble(key)) ?? defaultValue;
String readString(String key, {String defaultValue = ''}) => readNullableString(key) ?? defaultValue;
List<String> readStringList(String key, {List<String> defaultValue = const []}) =>
_readTolerant(key, () => _cache.getStringList(key)) ?? defaultValue;
/// Typed write helpers — symmetric with the read helpers above; use these
/// instead of `prefs.setX(...)` so call sites stay terse.
Future<void> writeBool(String key, bool value) => _cache.setBool(key, value);
Future<void> writeInt(String key, int value) => _cache.setInt(key, value);
Future<void> writeDouble(String key, double value) => _cache.setDouble(key, value);
Future<void> writeString(String key, String value) => _cache.setString(key, value);
Future<void> writeStringList(String key, List<String> value) => _cache.setStringList(key, value);
/// Decode a JSON string to a Map with error handling.
///
/// If [legacyStringOk] is true and the value is a plain string (not valid
/// JSON), returns `{'key': jsonString, 'descending': false}` for legacy
/// library sort compatibility.
Map<String, dynamic> decodeJsonStringToMap(String jsonString, {bool legacyStringOk = false}) {
try {
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
if (legacyStringOk) {
return {'key': jsonString, 'descending': false};
}
return {};
}
}
/// Read a value typed by [pref]; falls back to its `defaultValue`.
T read<T>(Pref<T> pref) => pref.readFrom(this);
/// Write a value typed by [pref]. Pushes the post-transform value into any
/// listenable previously vended for this key so widgets rebuild automatically.
Future<void> write<T>(Pref<T> pref, T value) async {
await pref.writeTo(this, value);
final n = _listenables[pref.key];
if (n != null) (n as ValueNotifier<T>).value = read(pref);
}
/// Lazy per-key [ValueNotifier]. Use with [ValueListenableBuilder] to rebuild
/// when the value changes. Notifiers live for the app lifetime — do not
/// dispose them.
final Map<String, ValueNotifier<dynamic>> _listenables = {};
final Map<String, Pref<dynamic>> _listenablePrefs = {};
ValueNotifier<T> listenable<T>(Pref<T> pref) => pref.bindListenable(this);
/// Type-erased [Listenable] accessor for combining multiple prefs into a
/// `Listenable.merge`. Dispatches through [Pref.bindListenable] so the
/// underlying notifier is created with the pref's concrete type.
Listenable listenableOf(Pref<Object?> pref) => pref.bindListenable(this);
/// Push current stored values into every active listenable. Used after bulk
/// operations that bypass [write] (reset/import/direct SharedPreferences writes).
void refreshActiveListenables() {
for (final pref in _listenablePrefs.values.toList(growable: false)) {
pref.refreshListenable(this);
}
}
/// Hook for subclass-specific initialization after SharedPreferences is ready.
///
/// Override this method to perform any setup that requires access to
/// SharedPreferences (e.g., registering values with other services).
Future<void> onInit() async {}
}
/// Typed preference declaration. Pair with [BaseSharedPreferencesService.read]
/// and [BaseSharedPreferencesService.write] to remove per-setting `?? default`
/// boilerplate.
abstract class Pref<T> {
final String key;
const Pref(this.key);
/// Implementation hook — call [BaseSharedPreferencesService.read] instead.
T readFrom(BaseSharedPreferencesService svc);
/// Implementation hook — call [BaseSharedPreferencesService.write] instead.
Future<void> writeTo(BaseSharedPreferencesService svc, T value);
/// Get-or-create the [ValueNotifier] for this pref. Virtual-dispatched via
/// the runtime [Pref] subclass so the notifier carries the concrete `T`,
/// even when called through a `Pref<Object?>` reference (used by
/// [BaseSharedPreferencesService.listenableOf]).
ValueNotifier<T> bindListenable(BaseSharedPreferencesService svc) {
final existing = svc._listenables[key];
svc._listenablePrefs[key] = this;
if (existing != null) return existing as ValueNotifier<T>;
final notifier = ValueNotifier<T>(readFrom(svc));
svc._listenables[key] = notifier;
return notifier;
}
/// If a listenable exists for this key, push the current stored value into
/// it. Used after bulk operations (reset, import) that bypass [writeTo].
/// No-op when no listener has been registered.
void refreshListenable(BaseSharedPreferencesService svc) {
final n = svc._listenables[key];
if (n != null) (n as ValueNotifier<T>).value = readFrom(svc);
}
}
class BoolPref extends Pref<bool> {
final bool defaultValue;
/// Lazily-resolved default for values that depend on state unavailable at
/// static-init time (e.g. async TV detection). Wins over [defaultValue].
final bool Function()? defaultValueProvider;
final void Function(bool)? onWrite;
const BoolPref(super.key, {this.defaultValue = false, this.defaultValueProvider, this.onWrite});
@override
bool readFrom(BaseSharedPreferencesService svc) =>
svc.readBool(key, defaultValue: defaultValueProvider?.call() ?? defaultValue);
@override
Future<void> writeTo(BaseSharedPreferencesService svc, bool value) async {
await svc.writeBool(key, value);
onWrite?.call(value);
}
}
class IntPref extends Pref<int> {
final int defaultValue;
final int Function(int)? transform;
const IntPref(super.key, {this.defaultValue = 0, this.transform});
@override
int readFrom(BaseSharedPreferencesService svc) {
final raw = svc.readInt(key, defaultValue: defaultValue);
return transform == null ? raw : transform!(raw);
}
@override
Future<void> writeTo(BaseSharedPreferencesService svc, int value) =>
svc.writeInt(key, transform == null ? value : transform!(value));
}
class DoublePref extends Pref<double> {
final double defaultValue;
final double Function(double)? transform;
const DoublePref(super.key, {this.defaultValue = 0.0, this.transform});
@override
double readFrom(BaseSharedPreferencesService svc) {
final raw = svc.readDouble(key, defaultValue: defaultValue);
return transform == null ? raw : transform!(raw);
}
@override
Future<void> writeTo(BaseSharedPreferencesService svc, double value) =>
svc.writeDouble(key, transform == null ? value : transform!(value));
}
class StringPref extends Pref<String> {
final String defaultValue;
const StringPref(super.key, {this.defaultValue = ''});
@override
String readFrom(BaseSharedPreferencesService svc) => svc.readString(key, defaultValue: defaultValue);
@override
Future<void> writeTo(BaseSharedPreferencesService svc, String value) => svc.writeString(key, value);
}
/// Like [StringPref] but null = key absent. [transform] runs on write; if it
/// returns null the key is removed.
class NullableStringPref extends Pref<String?> {
final String? Function(String?)? transform;
const NullableStringPref(super.key, {this.transform});
@override
String? readFrom(BaseSharedPreferencesService svc) => svc.readNullableString(key);
@override
Future<void> writeTo(BaseSharedPreferencesService svc, String? value) async {
final normalized = transform == null ? value : transform!(value);
if (normalized == null) {
await svc.prefs.remove(key);
} else {
await svc.writeString(key, normalized);
}
}
}
class StringListPref extends Pref<List<String>> {
final List<String> defaultValue;
const StringListPref(super.key, {this.defaultValue = const []});
@override
List<String> readFrom(BaseSharedPreferencesService svc) => svc.readStringList(key, defaultValue: defaultValue);
@override
Future<void> writeTo(BaseSharedPreferencesService svc, List<String> value) => svc.writeStringList(key, value);
}
/// Stores an enum by its [Enum.name]; falls back to the default when the
/// stored string doesn't match any value in [values].
///
/// Exactly one of [defaultValue] / [defaultValueProvider] must be given; the
/// provider form resolves at read time, for defaults that depend on state
/// unavailable at static-init time (e.g. async TV detection).
class EnumPref<T extends Enum> extends Pref<T> {
final List<T> values;
final T? defaultValue;
final T Function()? defaultValueProvider;
const EnumPref(super.key, {required this.values, this.defaultValue, this.defaultValueProvider})
: assert((defaultValue != null) != (defaultValueProvider != null));
T get _default => defaultValueProvider?.call() ?? defaultValue!;
@override
T readFrom(BaseSharedPreferencesService svc) {
final stored = svc.readNullableString(key);
if (stored == null) return _default;
return values.firstWhere((v) => v.name == stored, orElse: () => _default);
}
@override
Future<void> writeTo(BaseSharedPreferencesService svc, T value) => svc.writeString(key, value.name);
}
/// Stores an arbitrary value as a JSON-encoded string. Decode failures and
/// missing keys both fall back to [defaultValue].
class JsonPref<T> extends Pref<T> {
final T defaultValue;
final String Function(T) encode;
final T Function(dynamic) decode;
JsonPref(super.key, {required this.defaultValue, required this.encode, required this.decode});
@override
T readFrom(BaseSharedPreferencesService svc) {
final s = svc.readNullableString(key);
if (s == null) return defaultValue;
try {
return decode(json.decode(s));
} catch (_) {
return defaultValue;
}
}
@override
Future<void> writeTo(BaseSharedPreferencesService svc, T value) => svc.writeString(key, encode(value));
}
/// Reads a preference, tolerating a stored value whose type no longer matches
/// the declaration.
///
/// `SharedPreferencesWithCache.getX` is an `as T?` cast, so a value written by
/// an older build, hand-edited, or partially recovered throws `TypeError`
/// rather than returning null. A value we cannot read is indistinguishable
/// from one that was never written, so drop the key and fall back to the
/// declared default instead of letting it propagate — before #1732 a single
/// mistyped preference could fail the entire startup gate.
///
/// Credential slots are exempt: silently dropping one would sign the user out
/// with no explanation. Those raise [UnreadableSensitivePreferenceException],
/// which the startup gate classifies as repairable so the user gets the same
/// consented repair as an unparseable store.
///
/// Takes the cache directly so the credential stores — which hold a
/// [SharedPreferencesWithCache] rather than a [BaseSharedPreferencesService] —
/// get the same treatment as the settings layer.
T? readPreferenceTolerantly<T>(SharedPreferencesWithCache cache, String key, T? Function() read) {
try {
return read();
} on TypeError catch (error, stackTrace) {
if (isSensitivePrefKey(key)) {
appLogger.e('Credential preference "$key" is unreadable', error: error, stackTrace: stackTrace);
Error.throwWithStackTrace(UnreadableSensitivePreferenceException(key, error), stackTrace);
}
appLogger.w('Dropping preference "$key" with an unreadable stored type', error: error, stackTrace: stackTrace);
unawaited(
cache.remove(key).catchError((Object e, StackTrace s) {
appLogger.d('Could not drop unreadable preference "$key"', error: e, stackTrace: s);
}),
);
return null;
}
}
/// Tolerant string read for a bare [SharedPreferencesWithCache].
String? readTolerantString(SharedPreferencesWithCache cache, String key) =>
readPreferenceTolerantly(cache, key, () => cache.getString(key));