Files
plezy/lib/services/base_shared_preferences_service.dart
T
edde746 7f0cad339c fix(startup): report and repair a failed launch instead of showing "Error"
Since 2.10.0 the whole app sits behind one all-or-nothing initialization
gate, and that gate discarded the only evidence of its own failure. It
caught the error, logged nothing but `error.runtimeType`, rendered an
icon plus the word "Error" plus Retry, and never reported the error
because catching it kept the crash reporter from ever seeing it. There
is no log file on any platform, the buffer is in memory only, a
double-clicked Windows release build has no console, and the log viewer
lives in Settings, behind the gate that just failed. #1732 is the result:
a Windows 11 user whose app will not boot and who cannot produce a single
byte of diagnostic detail.

The gate now names its phases. Each step is wrapped so a throw carries
the phase it came from, replacing a `Future.wait` that discarded every
error but the first and could not attribute it to any of four concurrent
steps. The failure screen renders the phase, the exception type, the
message and an expandable stack, plus copy and upload actions that reuse
the existing log-relay flow. The record is persisted next to the database
so the next successful launch can surface it in Settings > Logs, and it
is reported to the crash reporter explicitly.

Only preferences and the database still gate the launch. Window chrome,
locale, crash-reporting init, TV/performance detection, the image-cache
budget and download storage are best-effort and time-bounded, so a
stalled platform thread degrades instead of holding the splash forever.
Sentry no longer receives the startup work as its `appRunner`: that made
a startup failure indistinguishable from a Sentry failure, and the guard
would then have re-run migrations and the database open a second time.

The two remaining fatal steps become recoverable. Preference reads
tolerate a value whose stored type no longer matches, dropping the key
and defaulting instead of failing the boot. A store that cannot be parsed
is detected before either desktop plugin backend can memoise it, which is
what makes an in-process repair possible at all. Repair is never
automatic: it states what it will cost, salvages the credential-vault key
and every tracker and Seerr session it can validate out of the damaged
bytes, reseeds them, and moves the original aside rather than deleting
it. Servers and profiles survive a salvaged key because their tokens are
ciphertext in the database; tracker and Seerr sessions are plaintext
preference entries, so the copy says they may still need reconnecting.

Nothing derived from the store reaches a diagnostic. `FormatException`
prints an excerpt of whatever it failed to parse, and during startup that
document holds the vault key, refresh tokens and session cookies while
the redaction manager still has nothing registered, so the wrapper keeps
only the cause's type and offset and the record is an allowlist of
already-redacted fields. The quarantined copy is labelled as containing
credentials, is never offered for upload, and can be deleted from the
dialog.

Also self-heals orphaned WAL/SHM sidecars on desktop rather than only
tvOS, makes every `createTable` migration step idempotent, keeps MSVC
link by-products out of the Windows bundle, and asserts bundle contents
in CI.

Refs #1732
2026-07-31 21:45:32 +02:00

507 lines
22 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) {
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;
}
}
/// Nullable string read routed through [_readTolerant]. Use instead of
/// `prefs.getString(...)` wherever a mistyped stored value must not throw.
String? readNullableString(String key) => _readTolerant(key, () => _cache.getString(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));
}