import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/app_logger.dart'; import '../services/base_shared_preferences_service.dart'; /// Startup result after reconciling the purgeable tvOS database with its /// bounded standard-domain recovery image. enum TvosDatabaseRecoveryOutcome { notApplicable, fresh, adoptedExistingDatabase, restored, recoveryRequired } /// The critical row group changed by a database mutation. enum TvosDatabaseRecoveryGroup { identity, pending } /// Deterministic fault-injection points for the recovery commit protocol. @visibleForTesting enum TvosDatabaseRecoveryCrashPoint { afterInvalidation, afterDatabaseMutation, afterPayloadWrite, afterFinalManifest } /// A durability failure that is deliberately free of protected row payloads. final class TvosDatabaseDurabilityException implements Exception { const TvosDatabaseDurabilityException(); @override String toString() => 'TvosDatabaseDurabilityException: critical local data was not durably committed'; } final class _TvosDatabaseRecoveryBudgetException implements Exception { const _TvosDatabaseRecoveryBudgetException(); } final class _TvosDatabaseRecoveryInvalidationException implements Exception { const _TvosDatabaseRecoveryInvalidationException(); } /// Raw critical rows from a validated, committed recovery image. /// /// Values are kept raw so already-protected connection configuration and user /// token bytes are restored exactly, without crossing a reveal boundary. final class TvosDatabaseRecoverySnapshot { const TvosDatabaseRecoverySnapshot({required this.identity, required this.pending}); final Map identity; final Map pending; } typedef TvosDatabaseRecoveryRowsReader = Future> Function(); typedef TvosDatabaseRecoveryRestore = Future Function(TvosDatabaseRecoverySnapshot snapshot); typedef TvosDatabaseRecoveryPriorInstallEvidence = Future Function(); typedef TvosDatabaseRecoveryDebugCrash = Future Function(TvosDatabaseRecoveryCrashPoint point); typedef TvosDatabaseRecoveryDebugBeforePreferenceWrite = Future Function(String key); /// Maintains the bounded two-group tvOS recovery image in UserDefaults.standard. /// /// The manifest is invalidated before a critical Drift mutation. The changed /// payload and its digest are written only after Drift commits, followed by the /// committed manifest as the final write. Therefore a missing database is /// restorable only from a complete committed image; an interrupted update /// always requires recovery instead of silently resurrecting stale state. final class TvosDatabaseRecoveryStore { TvosDatabaseRecoveryStore( this._preferences, { this.isTvos = false, this.preferenceImageByteCeiling = defaultPreferenceImageByteCeiling, this.debugCrash, this.debugBeforePreferenceWrite, }); static const int recoveryFormatVersion = 1; static const int defaultPreferenceImageByteCeiling = 400000; static const String manifestKey = 'tvos_db_recovery_manifest_v1'; static const String identityKey = 'tvos_db_recovery_identity_v1'; static const String pendingKey = 'tvos_db_recovery_pending_v1'; static const String recoveryRequiredKey = 'tvos_db_recovery_required_v1'; static const String keyPrefix = 'tvos_db_recovery_'; static const String _stateInvalidated = 'invalidated'; static const String _stateCommitted = 'committed'; final SharedPreferencesWithCache _preferences; final bool isTvos; final int preferenceImageByteCeiling; final TvosDatabaseRecoveryDebugCrash? debugCrash; final TvosDatabaseRecoveryDebugBeforePreferenceWrite? debugBeforePreferenceWrite; bool _recoveryDisabled = false; bool _manifestCacheNeedsReload = false; bool _pendingPayloadTruncated = false; /// Reconciles startup before any registry, legacy bootstrap, or UI consumer. Future reconcile({ required bool databaseExisted, required TvosDatabaseRecoveryRowsReader readIdentity, required TvosDatabaseRecoveryRowsReader readPending, required TvosDatabaseRecoveryRestore restore, required TvosDatabaseRecoveryPriorInstallEvidence hasPriorInstallEvidence, }) async { if (!isTvos) return TvosDatabaseRecoveryOutcome.notApplicable; // Tolerant read: `reconcile` runs inside `AppDatabase.open`, a fatal // startup step, so a wrong-typed marker would veto the launch outright on // a first-class TV target. An unreadable marker tells us nothing, which is // the same position as an absent one — default false and drop the key // (#1732). The manifest reads below are already inside catch-alls. final recoveryRequired = readPreferenceTolerantly(_preferences, recoveryRequiredKey, () => _preferences.getBool(recoveryRequiredKey)) ?? false; if (recoveryRequired) { await _reloadManifestCacheIfNeeded(); final snapshot = _readCommittedSnapshot(); if (snapshot == null) return TvosDatabaseRecoveryOutcome.recoveryRequired; try { return await _restoreCommittedSnapshot( snapshot: snapshot, restore: restore, readIdentity: readIdentity, readPending: readPending, ); } catch (_) { return TvosDatabaseRecoveryOutcome.recoveryRequired; } } if (databaseExisted) { // The database is authoritative. Read it outside the recovery publishing // failure boundary so database failures are never mistaken for damaged // recovery evidence. final identityRows = await readIdentity(); final pendingRows = await readPending(); try { await _publishAuthoritativeRows(identityRows: identityRows, pendingRows: pendingRows); return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase; } on _TvosDatabaseRecoveryInvalidationException { // The old committed image may still be restorable. Keep recovery // enabled so every later critical mutation must retry invalidation // before it is allowed to touch the authoritative database. _recoveryDisabled = false; return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase; } catch (error, stackTrace) { _disableRecovery(error, stackTrace); return TvosDatabaseRecoveryOutcome.adoptedExistingDatabase; } } final hasAnyRecoveryKey = _preferences.keys.any((key) => key.startsWith(keyPrefix)); if (!hasAnyRecoveryKey) { if (await hasPriorInstallEvidence()) { return _markRecoveryRequired(); } try { await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending); return TvosDatabaseRecoveryOutcome.fresh; } on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) { _disableRecovery(error, stackTrace); return TvosDatabaseRecoveryOutcome.fresh; } catch (_) { return _markRecoveryRequired(); } } final snapshot = _readCommittedSnapshot(); if (snapshot == null) return _markRecoveryRequired(); try { return await _restoreCommittedSnapshot( snapshot: snapshot, restore: restore, readIdentity: readIdentity, readPending: readPending, ); } catch (_) { return _markRecoveryRequired(); } } Future _restoreCommittedSnapshot({ required TvosDatabaseRecoverySnapshot snapshot, required TvosDatabaseRecoveryRestore restore, required TvosDatabaseRecoveryRowsReader readIdentity, required TvosDatabaseRecoveryRowsReader readPending, }) async { await restore(snapshot); // The restored database may have migrated legacy plaintext credentials. // Publish a replacement image before clearing the replay marker so the // committed preference copy is protected as well. await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending); await _clearRecoveryRequired(); return TvosDatabaseRecoveryOutcome.restored; } Future _markRecoveryRequired() async { try { await debugBeforePreferenceWrite?.call(recoveryRequiredKey); await _preferences.setBool(recoveryRequiredKey, true); } catch (_) { throw const TvosDatabaseDurabilityException(); } return TvosDatabaseRecoveryOutcome.recoveryRequired; } Future _clearRecoveryRequired() async { // Always issue the removal. SharedPreferencesWithCache can update its // cache before the platform write finishes, so a failed removal may make // the key look absent locally while it remains durable. try { await debugBeforePreferenceWrite?.call(recoveryRequiredKey); await _preferences.remove(recoveryRequiredKey); } catch (_) { throw const TvosDatabaseDurabilityException(); } } /// Runs one complete critical mutation and resolves only after its recovery /// image is committed. Off tvOS this is a zero-storage wrapper. Future runDurableMutation({ required TvosDatabaseRecoveryGroup group, required Future Function() mutation, required TvosDatabaseRecoveryRowsReader readIdentity, required TvosDatabaseRecoveryRowsReader readPending, }) async { if (!isTvos) return mutation(); if (_recoveryDisabled) return mutation(); await _reloadManifestCacheIfNeeded(); final previous = _readCommittedManifestForMutation(); // Invalidating the old image is mandatory even when the preference // domain is already over budget: stale identity must never become // restorable after the database mutation commits. try { await _invalidateRecoveryImage(previous); } on _TvosDatabaseRecoveryInvalidationException { throw const TvosDatabaseDurabilityException(); } await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterInvalidation); late final T result; late final Map rows; try { result = await mutation(); await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterDatabaseMutation); rows = await (group == TvosDatabaseRecoveryGroup.identity ? readIdentity() : readPending()); } catch (error, stackTrace) { // The database may already have committed, so the previous recovery // image is no longer safe to restore. Keep it invalidated and let later // mutations use the authoritative database for the rest of this process. _disableRecovery(error, stackTrace); rethrow; } try { await _commitChangedGroup(previous: previous, group: group, rows: rows); } on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) { _disableRecovery(error, stackTrace); } on TvosDatabaseDurabilityException catch (error, stackTrace) { if (debugCrash != null || debugBeforePreferenceWrite != null) rethrow; _disableRecovery(error, stackTrace); } return result; } /// Replaces an irrecoverable image with the current authoritative database /// only after the user explicitly starts a new sign-in. Future acknowledgeRecoveryRequired({ required TvosDatabaseRecoveryRowsReader readIdentity, required TvosDatabaseRecoveryRowsReader readPending, }) async { if (!isTvos) return; try { await _commitAuthoritativeDatabase(readIdentity: readIdentity, readPending: readPending); await _clearRecoveryRequired(); _recoveryDisabled = false; } on _TvosDatabaseRecoveryInvalidationException { throw const TvosDatabaseDurabilityException(); } on _TvosDatabaseRecoveryBudgetException catch (error, stackTrace) { _disableRecovery(error, stackTrace); throw const TvosDatabaseDurabilityException(); } } Future _commitAuthoritativeDatabase({ required TvosDatabaseRecoveryRowsReader readIdentity, required TvosDatabaseRecoveryRowsReader readPending, }) async { final identityRows = await readIdentity(); final pendingRows = await readPending(); await _publishAuthoritativeRows(identityRows: identityRows, pendingRows: pendingRows); } Future _publishAuthoritativeRows({ required Map identityRows, required Map pendingRows, }) async { await _reloadManifestCacheIfNeeded(); final previous = _readManifestLenient(); await _invalidateRecoveryImage(previous); final identityPayload = _encodePayload(identityRows); final pendingPayload = _encodePayload(pendingRows); final manifest = _Manifest( state: _stateCommitted, identityDigest: _digest(identityPayload), pendingDigest: _digest(pendingPayload), ); await _commitGeneration( payloads: {identityKey: identityPayload, pendingKey: pendingPayload}, manifest: manifest, reportsPendingPayloadState: true, ); } Future _commitChangedGroup({ required _Manifest previous, required TvosDatabaseRecoveryGroup group, required Map rows, }) async { final payloadKey = group == TvosDatabaseRecoveryGroup.identity ? identityKey : pendingKey; final payload = _encodePayload(rows); final digest = _digest(payload); final manifest = switch (group) { TvosDatabaseRecoveryGroup.identity => previous.copyWith(state: _stateCommitted, identityDigest: digest), TvosDatabaseRecoveryGroup.pending => previous.copyWith(state: _stateCommitted, pendingDigest: digest), }; await _commitGeneration( payloads: {payloadKey: payload}, manifest: manifest, reportsPendingPayloadState: group == TvosDatabaseRecoveryGroup.pending, ); } Future _commitGeneration({ required Map payloads, required _Manifest manifest, required bool reportsPendingPayloadState, }) async { final committedPayloads = Map.of(payloads); var committedManifest = manifest; var replacements = {...committedPayloads, manifestKey: _encodeManifest(committedManifest)}; var pendingTruncated = false; if (!_candidateFits(replacements)) { final emptyPendingPayload = _encodePayload(_emptyPendingRows); committedPayloads[pendingKey] = emptyPendingPayload; committedManifest = committedManifest.copyWith(pendingDigest: _digest(emptyPendingPayload)); replacements = {...committedPayloads, manifestKey: _encodeManifest(committedManifest)}; pendingTruncated = true; } _requireCandidateFits(replacements); if (reportsPendingPayloadState || pendingTruncated) { _markPendingPayloadTruncated(pendingTruncated); } try { for (final entry in committedPayloads.entries) { await debugBeforePreferenceWrite?.call(entry.key); await _preferences.setString(entry.key, entry.value); } await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterPayloadWrite); await debugBeforePreferenceWrite?.call(manifestKey); await _preferences.setString(manifestKey, _encodeManifest(committedManifest)); await debugCrash?.call(TvosDatabaseRecoveryCrashPoint.afterFinalManifest); } catch (_) { throw const TvosDatabaseDurabilityException(); } } TvosDatabaseRecoverySnapshot? _readCommittedSnapshot() { try { final manifest = _decodeManifest(_preferences.getString(manifestKey)); if (manifest == null || manifest.state != _stateCommitted) return null; if (_currentPreferenceImageSize() > preferenceImageByteCeiling) return null; final identityRaw = _preferences.getString(identityKey); final pendingRaw = _preferences.getString(pendingKey); if (identityRaw == null || pendingRaw == null) return null; if (_digest(identityRaw) != manifest.identityDigest || _digest(pendingRaw) != manifest.pendingDigest) { return null; } final identity = _decodePayload(identityRaw, _identityRowKeys); final pending = _decodePayload(pendingRaw, _pendingRowKeys); if (identity == null || pending == null) return null; return TvosDatabaseRecoverySnapshot(identity: identity, pending: pending); } catch (_) { return null; } } _Manifest _readCommittedManifestForMutation() { try { final manifest = _decodeManifest(_preferences.getString(manifestKey)); final identityRaw = _preferences.getString(identityKey); final pendingRaw = _preferences.getString(pendingKey); if (manifest == null || manifest.state != _stateCommitted || identityRaw == null || pendingRaw == null || _digest(identityRaw) != manifest.identityDigest || _digest(pendingRaw) != manifest.pendingDigest) { throw const TvosDatabaseDurabilityException(); } return manifest; } catch (_) { throw const TvosDatabaseDurabilityException(); } } _Manifest? _readManifestLenient() { try { return _decodeManifest(_preferences.getString(manifestKey)); } catch (_) { return null; } } Future _invalidateRecoveryImage(_Manifest? previous) async { try { await _writeManifest(_invalidatedManifest(previous), enforceBudget: false); } on TvosDatabaseDurabilityException { if (previous?.state == _stateCommitted) { throw const _TvosDatabaseRecoveryInvalidationException(); } rethrow; } } Future _writeManifest(_Manifest manifest, {bool enforceBudget = true}) async { final encoded = _encodeManifest(manifest); if (enforceBudget) _requireCandidateFits({manifestKey: encoded}); try { await debugBeforePreferenceWrite?.call(manifestKey); await _preferences.setString(manifestKey, encoded); } catch (_) { // SharedPreferencesWithCache updates its local value before awaiting the // platform write. Reload the durable domain so a failed invalidation // cannot leave an optimistic "invalidated" manifest blocking retries. _manifestCacheNeedsReload = true; try { await _reloadManifestCacheIfNeeded(); } catch (_) { // The next mutation retries the durable reload before reading state. } throw const TvosDatabaseDurabilityException(); } } Future _reloadManifestCacheIfNeeded() async { if (!_manifestCacheNeedsReload) return; try { await _preferences.reloadCache(); _manifestCacheNeedsReload = false; } catch (_) { throw const TvosDatabaseDurabilityException(); } } _Manifest _invalidatedManifest(_Manifest? previous) => _Manifest( state: _stateInvalidated, identityDigest: previous?.identityDigest ?? '', pendingDigest: previous?.pendingDigest ?? '', ); bool _candidateFits(Map replacements) => _preferenceImageSize({recoveryRequiredKey: true, ...replacements}) <= preferenceImageByteCeiling; void _requireCandidateFits(Map replacements) { if (!_candidateFits(replacements)) { throw const _TvosDatabaseRecoveryBudgetException(); } } int _currentPreferenceImageSize() => _preferenceImageSize(const {}); int _preferenceImageSize(Map replacements) { final keys = {..._preferences.keys.where((key) => key.startsWith(keyPrefix)), ...replacements.keys}.toList() ..sort(); final image = {}; for (final key in keys) { image[key] = replacements.containsKey(key) ? replacements[key] : _preferences.get(key); } return utf8.encode(jsonEncode(image)).length; } void _markPendingPayloadTruncated(bool truncated) { if (truncated && !_pendingPayloadTruncated) { appLogger.w('tvOS database recovery omitted pending watch progress to stay within its preference budget'); } _pendingPayloadTruncated = truncated; } void _disableRecovery(Object error, StackTrace stackTrace) { if (_recoveryDisabled) return; _recoveryDisabled = true; appLogger.w( 'tvOS database recovery disabled for this process; the authoritative database remains available', error: error, stackTrace: stackTrace, ); } static const Set _identityRowKeys = {'connections', 'profiles', 'profileConnections'}; static const Set _pendingRowKeys = {'offlineWatchProgress'}; static const Map _emptyPendingRows = {'offlineWatchProgress': []}; static String _encodePayload(Map rows) => jsonEncode({'version': recoveryFormatVersion, 'rows': rows}); static Map? _decodePayload(String raw, Set expectedKeys) { final decoded = jsonDecode(raw); if (decoded is! Map || decoded.length != 2 || decoded['version'] != recoveryFormatVersion) { return null; } final rows = decoded['rows']; if (rows is! Map || rows.keys.toSet().difference(expectedKeys).isNotEmpty || rows.length != expectedKeys.length) { return null; } for (final key in expectedKeys) { final value = rows[key]; if (value is! List || value.any((row) => row is! Map)) return null; } return Map.unmodifiable(rows); } static String _digest(String value) => sha256.convert(utf8.encode(value)).toString(); static String _encodeManifest(_Manifest manifest) => jsonEncode({ 'version': recoveryFormatVersion, 'state': manifest.state, 'identityDigest': manifest.identityDigest, 'pendingDigest': manifest.pendingDigest, }); static _Manifest? _decodeManifest(String? raw) { if (raw == null) return null; final decoded = jsonDecode(raw); if (decoded is! Map || decoded.length != 4 || decoded['version'] != recoveryFormatVersion || decoded['state'] is! String || decoded['identityDigest'] is! String || decoded['pendingDigest'] is! String) { return null; } final state = decoded['state'] as String; final identityDigest = decoded['identityDigest'] as String; final pendingDigest = decoded['pendingDigest'] as String; if ((state != _stateInvalidated && state != _stateCommitted) || (state == _stateCommitted && (identityDigest.isEmpty || pendingDigest.isEmpty))) { return null; } return _Manifest(state: state, identityDigest: identityDigest, pendingDigest: pendingDigest); } } final class _Manifest { const _Manifest({required this.state, required this.identityDigest, required this.pendingDigest}); final String state; final String identityDigest; final String pendingDigest; _Manifest copyWith({String? state, String? identityDigest, String? pendingDigest}) => _Manifest( state: state ?? this.state, identityDigest: identityDigest ?? this.identityDigest, pendingDigest: pendingDigest ?? this.pendingDigest, ); }