Files
plezy/lib/database/tvos_database_recovery_store.dart
T

563 lines
22 KiB
Dart

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';
/// 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<String, Object?> identity;
final Map<String, Object?> pending;
}
typedef TvosDatabaseRecoveryRowsReader = Future<Map<String, Object?>> Function();
typedef TvosDatabaseRecoveryRestore = Future<void> Function(TvosDatabaseRecoverySnapshot snapshot);
typedef TvosDatabaseRecoveryPriorInstallEvidence = Future<bool> Function();
typedef TvosDatabaseRecoveryDebugCrash = Future<void> Function(TvosDatabaseRecoveryCrashPoint point);
typedef TvosDatabaseRecoveryDebugBeforePreferenceWrite = Future<void> 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<TvosDatabaseRecoveryOutcome> reconcile({
required bool databaseExisted,
required TvosDatabaseRecoveryRowsReader readIdentity,
required TvosDatabaseRecoveryRowsReader readPending,
required TvosDatabaseRecoveryRestore restore,
required TvosDatabaseRecoveryPriorInstallEvidence hasPriorInstallEvidence,
}) async {
if (!isTvos) return TvosDatabaseRecoveryOutcome.notApplicable;
final recoveryRequired = _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<TvosDatabaseRecoveryOutcome> _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<TvosDatabaseRecoveryOutcome> _markRecoveryRequired() async {
try {
await debugBeforePreferenceWrite?.call(recoveryRequiredKey);
await _preferences.setBool(recoveryRequiredKey, true);
} catch (_) {
throw const TvosDatabaseDurabilityException();
}
return TvosDatabaseRecoveryOutcome.recoveryRequired;
}
Future<void> _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<T> runDurableMutation<T>({
required TvosDatabaseRecoveryGroup group,
required Future<T> 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<String, Object?> 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<void> 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<void> _commitAuthoritativeDatabase({
required TvosDatabaseRecoveryRowsReader readIdentity,
required TvosDatabaseRecoveryRowsReader readPending,
}) async {
final identityRows = await readIdentity();
final pendingRows = await readPending();
await _publishAuthoritativeRows(identityRows: identityRows, pendingRows: pendingRows);
}
Future<void> _publishAuthoritativeRows({
required Map<String, Object?> identityRows,
required Map<String, Object?> 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<void> _commitChangedGroup({
required _Manifest previous,
required TvosDatabaseRecoveryGroup group,
required Map<String, Object?> 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<void> _commitGeneration({
required Map<String, String> payloads,
required _Manifest manifest,
required bool reportsPendingPayloadState,
}) async {
final committedPayloads = Map<String, String>.of(payloads);
var committedManifest = manifest;
var replacements = <String, String>{...committedPayloads, manifestKey: _encodeManifest(committedManifest)};
var pendingTruncated = false;
if (!_candidateFits(replacements)) {
final emptyPendingPayload = _encodePayload(_emptyPendingRows);
committedPayloads[pendingKey] = emptyPendingPayload;
committedManifest = committedManifest.copyWith(pendingDigest: _digest(emptyPendingPayload));
replacements = <String, String>{...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<void> _invalidateRecoveryImage(_Manifest? previous) async {
try {
await _writeManifest(_invalidatedManifest(previous), enforceBudget: false);
} on TvosDatabaseDurabilityException {
if (previous?.state == _stateCommitted) {
throw const _TvosDatabaseRecoveryInvalidationException();
}
rethrow;
}
}
Future<void> _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<void> _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<String, Object?> replacements) =>
_preferenceImageSize({recoveryRequiredKey: true, ...replacements}) <= preferenceImageByteCeiling;
void _requireCandidateFits(Map<String, Object?> replacements) {
if (!_candidateFits(replacements)) {
throw const _TvosDatabaseRecoveryBudgetException();
}
}
int _currentPreferenceImageSize() => _preferenceImageSize(const {});
int _preferenceImageSize(Map<String, Object?> replacements) {
final keys = <String>{..._preferences.keys.where((key) => key.startsWith(keyPrefix)), ...replacements.keys}.toList()
..sort();
final image = <String, Object?>{};
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<String> _identityRowKeys = {'connections', 'profiles', 'profileConnections'};
static const Set<String> _pendingRowKeys = {'offlineWatchProgress'};
static const Map<String, Object?> _emptyPendingRows = {'offlineWatchProgress': <Object?>[]};
static String _encodePayload(Map<String, Object?> rows) =>
jsonEncode({'version': recoveryFormatVersion, 'rows': rows});
static Map<String, Object?>? _decodePayload(String raw, Set<String> expectedKeys) {
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic> || decoded.length != 2 || decoded['version'] != recoveryFormatVersion) {
return null;
}
final rows = decoded['rows'];
if (rows is! Map<String, dynamic> ||
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<String, dynamic>)) return null;
}
return Map<String, Object?>.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<String, dynamic> ||
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,
);
}