fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
File diff suppressed because it is too large Load Diff
+227 -4
View File
@@ -153,6 +153,17 @@ class $DownloadedMediaTable extends DownloadedMedia
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _safRootUriMeta = const VerificationMeta(
'safRootUri',
);
@override
late final GeneratedColumn<String> safRootUri = GeneratedColumn<String>(
'saf_root_uri',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _thumbPathMeta = const VerificationMeta(
'thumbPath',
);
@@ -247,6 +258,7 @@ class $DownloadedMediaTable extends DownloadedMedia
totalBytes,
downloadedBytes,
videoFilePath,
safRootUri,
thumbPath,
downloadedAt,
errorMessage,
@@ -367,6 +379,15 @@ class $DownloadedMediaTable extends DownloadedMedia
),
);
}
if (data.containsKey('saf_root_uri')) {
context.handle(
_safRootUriMeta,
safRootUri.isAcceptableOrUnknown(
data['saf_root_uri']!,
_safRootUriMeta,
),
);
}
if (data.containsKey('thumb_path')) {
context.handle(
_thumbPathMeta,
@@ -479,6 +500,10 @@ class $DownloadedMediaTable extends DownloadedMedia
DriftSqlType.string,
data['${effectivePrefix}video_file_path'],
),
safRootUri: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}saf_root_uri'],
),
thumbPath: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}thumb_path'],
@@ -531,6 +556,7 @@ class DownloadedMediaItem extends DataClass
final int? totalBytes;
final int downloadedBytes;
final String? videoFilePath;
final String? safRootUri;
final String? thumbPath;
final int? downloadedAt;
final String? errorMessage;
@@ -552,6 +578,7 @@ class DownloadedMediaItem extends DataClass
this.totalBytes,
required this.downloadedBytes,
this.videoFilePath,
this.safRootUri,
this.thumbPath,
this.downloadedAt,
this.errorMessage,
@@ -586,6 +613,9 @@ class DownloadedMediaItem extends DataClass
if (!nullToAbsent || videoFilePath != null) {
map['video_file_path'] = Variable<String>(videoFilePath);
}
if (!nullToAbsent || safRootUri != null) {
map['saf_root_uri'] = Variable<String>(safRootUri);
}
if (!nullToAbsent || thumbPath != null) {
map['thumb_path'] = Variable<String>(thumbPath);
}
@@ -631,6 +661,9 @@ class DownloadedMediaItem extends DataClass
videoFilePath: videoFilePath == null && nullToAbsent
? const Value.absent()
: Value(videoFilePath),
safRootUri: safRootUri == null && nullToAbsent
? const Value.absent()
: Value(safRootUri),
thumbPath: thumbPath == null && nullToAbsent
? const Value.absent()
: Value(thumbPath),
@@ -672,6 +705,7 @@ class DownloadedMediaItem extends DataClass
totalBytes: serializer.fromJson<int?>(json['totalBytes']),
downloadedBytes: serializer.fromJson<int>(json['downloadedBytes']),
videoFilePath: serializer.fromJson<String?>(json['videoFilePath']),
safRootUri: serializer.fromJson<String?>(json['safRootUri']),
thumbPath: serializer.fromJson<String?>(json['thumbPath']),
downloadedAt: serializer.fromJson<int?>(json['downloadedAt']),
errorMessage: serializer.fromJson<String?>(json['errorMessage']),
@@ -698,6 +732,7 @@ class DownloadedMediaItem extends DataClass
'totalBytes': serializer.toJson<int?>(totalBytes),
'downloadedBytes': serializer.toJson<int>(downloadedBytes),
'videoFilePath': serializer.toJson<String?>(videoFilePath),
'safRootUri': serializer.toJson<String?>(safRootUri),
'thumbPath': serializer.toJson<String?>(thumbPath),
'downloadedAt': serializer.toJson<int?>(downloadedAt),
'errorMessage': serializer.toJson<String?>(errorMessage),
@@ -722,6 +757,7 @@ class DownloadedMediaItem extends DataClass
Value<int?> totalBytes = const Value.absent(),
int? downloadedBytes,
Value<String?> videoFilePath = const Value.absent(),
Value<String?> safRootUri = const Value.absent(),
Value<String?> thumbPath = const Value.absent(),
Value<int?> downloadedAt = const Value.absent(),
Value<String?> errorMessage = const Value.absent(),
@@ -751,6 +787,7 @@ class DownloadedMediaItem extends DataClass
videoFilePath: videoFilePath.present
? videoFilePath.value
: this.videoFilePath,
safRootUri: safRootUri.present ? safRootUri.value : this.safRootUri,
thumbPath: thumbPath.present ? thumbPath.value : this.thumbPath,
downloadedAt: downloadedAt.present ? downloadedAt.value : this.downloadedAt,
errorMessage: errorMessage.present ? errorMessage.value : this.errorMessage,
@@ -788,6 +825,9 @@ class DownloadedMediaItem extends DataClass
videoFilePath: data.videoFilePath.present
? data.videoFilePath.value
: this.videoFilePath,
safRootUri: data.safRootUri.present
? data.safRootUri.value
: this.safRootUri,
thumbPath: data.thumbPath.present ? data.thumbPath.value : this.thumbPath,
downloadedAt: data.downloadedAt.present
? data.downloadedAt.value
@@ -824,6 +864,7 @@ class DownloadedMediaItem extends DataClass
..write('totalBytes: $totalBytes, ')
..write('downloadedBytes: $downloadedBytes, ')
..write('videoFilePath: $videoFilePath, ')
..write('safRootUri: $safRootUri, ')
..write('thumbPath: $thumbPath, ')
..write('downloadedAt: $downloadedAt, ')
..write('errorMessage: $errorMessage, ')
@@ -836,7 +877,7 @@ class DownloadedMediaItem extends DataClass
}
@override
int get hashCode => Object.hash(
int get hashCode => Object.hashAll([
id,
serverId,
clientScopeId,
@@ -850,6 +891,7 @@ class DownloadedMediaItem extends DataClass
totalBytes,
downloadedBytes,
videoFilePath,
safRootUri,
thumbPath,
downloadedAt,
errorMessage,
@@ -857,7 +899,7 @@ class DownloadedMediaItem extends DataClass
bgTaskId,
mediaIndex,
mediaSourceId,
);
]);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -875,6 +917,7 @@ class DownloadedMediaItem extends DataClass
other.totalBytes == this.totalBytes &&
other.downloadedBytes == this.downloadedBytes &&
other.videoFilePath == this.videoFilePath &&
other.safRootUri == this.safRootUri &&
other.thumbPath == this.thumbPath &&
other.downloadedAt == this.downloadedAt &&
other.errorMessage == this.errorMessage &&
@@ -898,6 +941,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
final Value<int?> totalBytes;
final Value<int> downloadedBytes;
final Value<String?> videoFilePath;
final Value<String?> safRootUri;
final Value<String?> thumbPath;
final Value<int?> downloadedAt;
final Value<String?> errorMessage;
@@ -919,6 +963,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
this.totalBytes = const Value.absent(),
this.downloadedBytes = const Value.absent(),
this.videoFilePath = const Value.absent(),
this.safRootUri = const Value.absent(),
this.thumbPath = const Value.absent(),
this.downloadedAt = const Value.absent(),
this.errorMessage = const Value.absent(),
@@ -941,6 +986,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
this.totalBytes = const Value.absent(),
this.downloadedBytes = const Value.absent(),
this.videoFilePath = const Value.absent(),
this.safRootUri = const Value.absent(),
this.thumbPath = const Value.absent(),
this.downloadedAt = const Value.absent(),
this.errorMessage = const Value.absent(),
@@ -967,6 +1013,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
Expression<int>? totalBytes,
Expression<int>? downloadedBytes,
Expression<String>? videoFilePath,
Expression<String>? safRootUri,
Expression<String>? thumbPath,
Expression<int>? downloadedAt,
Expression<String>? errorMessage,
@@ -990,6 +1037,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
if (totalBytes != null) 'total_bytes': totalBytes,
if (downloadedBytes != null) 'downloaded_bytes': downloadedBytes,
if (videoFilePath != null) 'video_file_path': videoFilePath,
if (safRootUri != null) 'saf_root_uri': safRootUri,
if (thumbPath != null) 'thumb_path': thumbPath,
if (downloadedAt != null) 'downloaded_at': downloadedAt,
if (errorMessage != null) 'error_message': errorMessage,
@@ -1014,6 +1062,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
Value<int?>? totalBytes,
Value<int>? downloadedBytes,
Value<String?>? videoFilePath,
Value<String?>? safRootUri,
Value<String?>? thumbPath,
Value<int?>? downloadedAt,
Value<String?>? errorMessage,
@@ -1036,6 +1085,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
totalBytes: totalBytes ?? this.totalBytes,
downloadedBytes: downloadedBytes ?? this.downloadedBytes,
videoFilePath: videoFilePath ?? this.videoFilePath,
safRootUri: safRootUri ?? this.safRootUri,
thumbPath: thumbPath ?? this.thumbPath,
downloadedAt: downloadedAt ?? this.downloadedAt,
errorMessage: errorMessage ?? this.errorMessage,
@@ -1090,6 +1140,9 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
if (videoFilePath.present) {
map['video_file_path'] = Variable<String>(videoFilePath.value);
}
if (safRootUri.present) {
map['saf_root_uri'] = Variable<String>(safRootUri.value);
}
if (thumbPath.present) {
map['thumb_path'] = Variable<String>(thumbPath.value);
}
@@ -1130,6 +1183,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
..write('totalBytes: $totalBytes, ')
..write('downloadedBytes: $downloadedBytes, ')
..write('videoFilePath: $videoFilePath, ')
..write('safRootUri: $safRootUri, ')
..write('thumbPath: $thumbPath, ')
..write('downloadedAt: $downloadedAt, ')
..write('errorMessage: $errorMessage, ')
@@ -1170,6 +1224,28 @@ class $DownloadOwnersTable extends DownloadOwners
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _backendMeta = const VerificationMeta(
'backend',
);
@override
late final GeneratedColumn<String> backend = GeneratedColumn<String>(
'backend',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _clientScopeIdMeta = const VerificationMeta(
'clientScopeId',
);
@override
late final GeneratedColumn<String> clientScopeId = GeneratedColumn<String>(
'client_scope_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@@ -1182,7 +1258,13 @@ class $DownloadOwnersTable extends DownloadOwners
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [profileId, globalKey, createdAt];
List<GeneratedColumn> get $columns => [
profileId,
globalKey,
backend,
clientScopeId,
createdAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
@@ -1211,6 +1293,21 @@ class $DownloadOwnersTable extends DownloadOwners
} else if (isInserting) {
context.missing(_globalKeyMeta);
}
if (data.containsKey('backend')) {
context.handle(
_backendMeta,
backend.isAcceptableOrUnknown(data['backend']!, _backendMeta),
);
}
if (data.containsKey('client_scope_id')) {
context.handle(
_clientScopeIdMeta,
clientScopeId.isAcceptableOrUnknown(
data['client_scope_id']!,
_clientScopeIdMeta,
),
);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
@@ -1236,6 +1333,14 @@ class $DownloadOwnersTable extends DownloadOwners
DriftSqlType.string,
data['${effectivePrefix}global_key'],
)!,
backend: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}backend'],
),
clientScopeId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}client_scope_id'],
),
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}created_at'],
@@ -1253,10 +1358,14 @@ class DownloadOwnerItem extends DataClass
implements Insertable<DownloadOwnerItem> {
final String profileId;
final String globalKey;
final String? backend;
final String? clientScopeId;
final int createdAt;
const DownloadOwnerItem({
required this.profileId,
required this.globalKey,
this.backend,
this.clientScopeId,
required this.createdAt,
});
@override
@@ -1264,6 +1373,12 @@ class DownloadOwnerItem extends DataClass
final map = <String, Expression>{};
map['profile_id'] = Variable<String>(profileId);
map['global_key'] = Variable<String>(globalKey);
if (!nullToAbsent || backend != null) {
map['backend'] = Variable<String>(backend);
}
if (!nullToAbsent || clientScopeId != null) {
map['client_scope_id'] = Variable<String>(clientScopeId);
}
map['created_at'] = Variable<int>(createdAt);
return map;
}
@@ -1272,6 +1387,12 @@ class DownloadOwnerItem extends DataClass
return DownloadOwnersCompanion(
profileId: Value(profileId),
globalKey: Value(globalKey),
backend: backend == null && nullToAbsent
? const Value.absent()
: Value(backend),
clientScopeId: clientScopeId == null && nullToAbsent
? const Value.absent()
: Value(clientScopeId),
createdAt: Value(createdAt),
);
}
@@ -1284,6 +1405,8 @@ class DownloadOwnerItem extends DataClass
return DownloadOwnerItem(
profileId: serializer.fromJson<String>(json['profileId']),
globalKey: serializer.fromJson<String>(json['globalKey']),
backend: serializer.fromJson<String?>(json['backend']),
clientScopeId: serializer.fromJson<String?>(json['clientScopeId']),
createdAt: serializer.fromJson<int>(json['createdAt']),
);
}
@@ -1293,6 +1416,8 @@ class DownloadOwnerItem extends DataClass
return <String, dynamic>{
'profileId': serializer.toJson<String>(profileId),
'globalKey': serializer.toJson<String>(globalKey),
'backend': serializer.toJson<String?>(backend),
'clientScopeId': serializer.toJson<String?>(clientScopeId),
'createdAt': serializer.toJson<int>(createdAt),
};
}
@@ -1300,16 +1425,26 @@ class DownloadOwnerItem extends DataClass
DownloadOwnerItem copyWith({
String? profileId,
String? globalKey,
Value<String?> backend = const Value.absent(),
Value<String?> clientScopeId = const Value.absent(),
int? createdAt,
}) => DownloadOwnerItem(
profileId: profileId ?? this.profileId,
globalKey: globalKey ?? this.globalKey,
backend: backend.present ? backend.value : this.backend,
clientScopeId: clientScopeId.present
? clientScopeId.value
: this.clientScopeId,
createdAt: createdAt ?? this.createdAt,
);
DownloadOwnerItem copyWithCompanion(DownloadOwnersCompanion data) {
return DownloadOwnerItem(
profileId: data.profileId.present ? data.profileId.value : this.profileId,
globalKey: data.globalKey.present ? data.globalKey.value : this.globalKey,
backend: data.backend.present ? data.backend.value : this.backend,
clientScopeId: data.clientScopeId.present
? data.clientScopeId.value
: this.clientScopeId,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
);
}
@@ -1319,36 +1454,47 @@ class DownloadOwnerItem extends DataClass
return (StringBuffer('DownloadOwnerItem(')
..write('profileId: $profileId, ')
..write('globalKey: $globalKey, ')
..write('backend: $backend, ')
..write('clientScopeId: $clientScopeId, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(profileId, globalKey, createdAt);
int get hashCode =>
Object.hash(profileId, globalKey, backend, clientScopeId, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is DownloadOwnerItem &&
other.profileId == this.profileId &&
other.globalKey == this.globalKey &&
other.backend == this.backend &&
other.clientScopeId == this.clientScopeId &&
other.createdAt == this.createdAt);
}
class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
final Value<String> profileId;
final Value<String> globalKey;
final Value<String?> backend;
final Value<String?> clientScopeId;
final Value<int> createdAt;
final Value<int> rowid;
const DownloadOwnersCompanion({
this.profileId = const Value.absent(),
this.globalKey = const Value.absent(),
this.backend = const Value.absent(),
this.clientScopeId = const Value.absent(),
this.createdAt = const Value.absent(),
this.rowid = const Value.absent(),
});
DownloadOwnersCompanion.insert({
required String profileId,
required String globalKey,
this.backend = const Value.absent(),
this.clientScopeId = const Value.absent(),
required int createdAt,
this.rowid = const Value.absent(),
}) : profileId = Value(profileId),
@@ -1357,12 +1503,16 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
static Insertable<DownloadOwnerItem> custom({
Expression<String>? profileId,
Expression<String>? globalKey,
Expression<String>? backend,
Expression<String>? clientScopeId,
Expression<int>? createdAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (profileId != null) 'profile_id': profileId,
if (globalKey != null) 'global_key': globalKey,
if (backend != null) 'backend': backend,
if (clientScopeId != null) 'client_scope_id': clientScopeId,
if (createdAt != null) 'created_at': createdAt,
if (rowid != null) 'rowid': rowid,
});
@@ -1371,12 +1521,16 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
DownloadOwnersCompanion copyWith({
Value<String>? profileId,
Value<String>? globalKey,
Value<String?>? backend,
Value<String?>? clientScopeId,
Value<int>? createdAt,
Value<int>? rowid,
}) {
return DownloadOwnersCompanion(
profileId: profileId ?? this.profileId,
globalKey: globalKey ?? this.globalKey,
backend: backend ?? this.backend,
clientScopeId: clientScopeId ?? this.clientScopeId,
createdAt: createdAt ?? this.createdAt,
rowid: rowid ?? this.rowid,
);
@@ -1391,6 +1545,12 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
if (globalKey.present) {
map['global_key'] = Variable<String>(globalKey.value);
}
if (backend.present) {
map['backend'] = Variable<String>(backend.value);
}
if (clientScopeId.present) {
map['client_scope_id'] = Variable<String>(clientScopeId.value);
}
if (createdAt.present) {
map['created_at'] = Variable<int>(createdAt.value);
}
@@ -1405,6 +1565,8 @@ class DownloadOwnersCompanion extends UpdateCompanion<DownloadOwnerItem> {
return (StringBuffer('DownloadOwnersCompanion(')
..write('profileId: $profileId, ')
..write('globalKey: $globalKey, ')
..write('backend: $backend, ')
..write('clientScopeId: $clientScopeId, ')
..write('createdAt: $createdAt, ')
..write('rowid: $rowid')
..write(')'))
@@ -5423,6 +5585,7 @@ typedef $$DownloadedMediaTableCreateCompanionBuilder =
Value<int?> totalBytes,
Value<int> downloadedBytes,
Value<String?> videoFilePath,
Value<String?> safRootUri,
Value<String?> thumbPath,
Value<int?> downloadedAt,
Value<String?> errorMessage,
@@ -5446,6 +5609,7 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder =
Value<int?> totalBytes,
Value<int> downloadedBytes,
Value<String?> videoFilePath,
Value<String?> safRootUri,
Value<String?> thumbPath,
Value<int?> downloadedAt,
Value<String?> errorMessage,
@@ -5529,6 +5693,11 @@ class $$DownloadedMediaTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get safRootUri => $composableBuilder(
column: $table.safRootUri,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get thumbPath => $composableBuilder(
column: $table.thumbPath,
builder: (column) => ColumnFilters(column),
@@ -5639,6 +5808,11 @@ class $$DownloadedMediaTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get safRootUri => $composableBuilder(
column: $table.safRootUri,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get thumbPath => $composableBuilder(
column: $table.thumbPath,
builder: (column) => ColumnOrderings(column),
@@ -5735,6 +5909,11 @@ class $$DownloadedMediaTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumn<String> get safRootUri => $composableBuilder(
column: $table.safRootUri,
builder: (column) => column,
);
GeneratedColumn<String> get thumbPath =>
$composableBuilder(column: $table.thumbPath, builder: (column) => column);
@@ -5817,6 +5996,7 @@ class $$DownloadedMediaTableTableManager
Value<int?> totalBytes = const Value.absent(),
Value<int> downloadedBytes = const Value.absent(),
Value<String?> videoFilePath = const Value.absent(),
Value<String?> safRootUri = const Value.absent(),
Value<String?> thumbPath = const Value.absent(),
Value<int?> downloadedAt = const Value.absent(),
Value<String?> errorMessage = const Value.absent(),
@@ -5838,6 +6018,7 @@ class $$DownloadedMediaTableTableManager
totalBytes: totalBytes,
downloadedBytes: downloadedBytes,
videoFilePath: videoFilePath,
safRootUri: safRootUri,
thumbPath: thumbPath,
downloadedAt: downloadedAt,
errorMessage: errorMessage,
@@ -5861,6 +6042,7 @@ class $$DownloadedMediaTableTableManager
Value<int?> totalBytes = const Value.absent(),
Value<int> downloadedBytes = const Value.absent(),
Value<String?> videoFilePath = const Value.absent(),
Value<String?> safRootUri = const Value.absent(),
Value<String?> thumbPath = const Value.absent(),
Value<int?> downloadedAt = const Value.absent(),
Value<String?> errorMessage = const Value.absent(),
@@ -5882,6 +6064,7 @@ class $$DownloadedMediaTableTableManager
totalBytes: totalBytes,
downloadedBytes: downloadedBytes,
videoFilePath: videoFilePath,
safRootUri: safRootUri,
thumbPath: thumbPath,
downloadedAt: downloadedAt,
errorMessage: errorMessage,
@@ -5923,6 +6106,8 @@ typedef $$DownloadOwnersTableCreateCompanionBuilder =
DownloadOwnersCompanion Function({
required String profileId,
required String globalKey,
Value<String?> backend,
Value<String?> clientScopeId,
required int createdAt,
Value<int> rowid,
});
@@ -5930,6 +6115,8 @@ typedef $$DownloadOwnersTableUpdateCompanionBuilder =
DownloadOwnersCompanion Function({
Value<String> profileId,
Value<String> globalKey,
Value<String?> backend,
Value<String?> clientScopeId,
Value<int> createdAt,
Value<int> rowid,
});
@@ -5953,6 +6140,16 @@ class $$DownloadOwnersTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get backend => $composableBuilder(
column: $table.backend,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get clientScopeId => $composableBuilder(
column: $table.clientScopeId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
@@ -5978,6 +6175,16 @@ class $$DownloadOwnersTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get backend => $composableBuilder(
column: $table.backend,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get clientScopeId => $composableBuilder(
column: $table.clientScopeId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
@@ -5999,6 +6206,14 @@ class $$DownloadOwnersTableAnnotationComposer
GeneratedColumn<String> get globalKey =>
$composableBuilder(column: $table.globalKey, builder: (column) => column);
GeneratedColumn<String> get backend =>
$composableBuilder(column: $table.backend, builder: (column) => column);
GeneratedColumn<String> get clientScopeId => $composableBuilder(
column: $table.clientScopeId,
builder: (column) => column,
);
GeneratedColumn<int> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
}
@@ -6042,11 +6257,15 @@ class $$DownloadOwnersTableTableManager
({
Value<String> profileId = const Value.absent(),
Value<String> globalKey = const Value.absent(),
Value<String?> backend = const Value.absent(),
Value<String?> clientScopeId = const Value.absent(),
Value<int> createdAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => DownloadOwnersCompanion(
profileId: profileId,
globalKey: globalKey,
backend: backend,
clientScopeId: clientScopeId,
createdAt: createdAt,
rowid: rowid,
),
@@ -6054,11 +6273,15 @@ class $$DownloadOwnersTableTableManager
({
required String profileId,
required String globalKey,
Value<String?> backend = const Value.absent(),
Value<String?> clientScopeId = const Value.absent(),
required int createdAt,
Value<int> rowid = const Value.absent(),
}) => DownloadOwnersCompanion.insert(
profileId: profileId,
globalKey: globalKey,
backend: backend,
clientScopeId: clientScopeId,
createdAt: createdAt,
rowid: rowid,
),
+435 -29
View File
@@ -1,20 +1,53 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import '../media/ids.dart';
import 'app_database.dart';
import '../models/download_models.dart';
import '../profiles/profile.dart';
import '../utils/active_client_scope.dart';
enum QueueDownloadOutcome {
/// A missing or retryable row was durably admitted to the queue.
admitted,
/// The row was already queued; only its queue policy was refreshed.
alreadyQueued,
/// The existing row is active, paused, or complete and was left unchanged.
unchanged,
}
extension DownloadDatabaseOperations on AppDatabase {
Future<void> addDownloadOwner({required String profileId, required String globalKey}) async {
Future<void> addDownloadOwner({
required String profileId,
required String globalKey,
String? backendId,
String? clientScopeId,
}) async {
if (profileId.isEmpty) return;
await into(downloadOwners).insert(
DownloadOwnersCompanion.insert(
profileId: profileId,
globalKey: globalKey,
createdAt: DateTime.now().millisecondsSinceEpoch,
),
mode: InsertMode.insertOrIgnore,
await customUpdate(
'''
INSERT INTO download_owners (
profile_id,
global_key,
backend,
client_scope_id,
created_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(profile_id, global_key) DO UPDATE SET
backend = COALESCE(excluded.backend, download_owners.backend),
client_scope_id = COALESCE(excluded.client_scope_id, download_owners.client_scope_id)
''',
variables: [
Variable<String>(profileId),
Variable<String>(globalKey),
Variable<String>(backendId),
Variable<String>(clientScopeId),
Variable<int>(DateTime.now().millisecondsSinceEpoch),
],
updates: {downloadOwners},
);
}
@@ -22,6 +55,53 @@ extension DownloadDatabaseOperations on AppDatabase {
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go();
}
/// Removes one owner from a shared download while keeping an incomplete
/// physical row usable by a remaining owner.
///
/// When there is no remaining valid owner, nothing is removed so callers
/// can delete the physical download before releasing its final durable
/// owner. Selection, scope rebinding, and owner removal share a transaction.
Future<({DownloadOwnerItem? removedOwner, bool hasRemainingOwner})>
removeSharedDownloadOwnerAndRebindIncompleteMedia({required String profileId, required String globalKey}) {
return transaction(() async {
final departingOwner = await getDownloadOwner(profileId: profileId, globalKey: globalKey);
final remainingOwners = (await _validDownloadOwnerRows(globalKey, excludingProfileId: profileId)).toList()
..sort((a, b) {
final aHasScope = a.clientScopeId?.isNotEmpty ?? false;
final bHasScope = b.clientScopeId?.isNotEmpty ?? false;
if (aHasScope != bHasScope) return aHasScope ? -1 : 1;
final createdAtComparison = a.createdAt.compareTo(b.createdAt);
return createdAtComparison != 0 ? createdAtComparison : a.profileId.compareTo(b.profileId);
});
if (remainingOwners.isEmpty) {
return (removedOwner: null, hasRemainingOwner: false);
}
if (departingOwner != null) {
final media = await getDownloadedMedia(globalKey);
final departingScope = departingOwner.clientScopeId;
if (media != null &&
media.status != DownloadStatus.completed.index &&
departingScope != null &&
departingScope.isNotEmpty &&
media.clientScopeId == departingScope) {
final replacementScope = remainingOwners.first.clientScopeId;
if (replacementScope != media.clientScopeId) {
await updateDownloadedMediaClientScope(globalKey, replacementScope);
}
}
await removeDownloadOwner(profileId: profileId, globalKey: globalKey);
}
return (removedOwner: departingOwner, hasRemainingOwner: true);
});
}
Future<DownloadOwnerItem?> getDownloadOwner({required String profileId, required String globalKey}) {
return (select(
downloadOwners,
)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).getSingleOrNull();
}
Future<void> clearAllDownloadOwners() async {
await delete(downloadOwners).go();
}
@@ -32,6 +112,28 @@ extension DownloadDatabaseOperations on AppDatabase {
return rows.map((row) => row.globalKey).toSet();
}
Future<List<DownloadOwnerItem>> getDownloadOwnersForProfile(String profileId) {
if (profileId.isEmpty) return Future.value(const []);
return (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
}
Future<void> updateDownloadOwnerScope({
required String profileId,
required String globalKey,
required String backendId,
required String clientScopeId,
}) {
return (update(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).write(
DownloadOwnersCompanion(backend: Value(backendId), clientScopeId: Value(clientScopeId)),
);
}
Future<void> updateDownloadedMediaClientScope(String globalKey, String? clientScopeId) {
return (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
DownloadedMediaCompanion(clientScopeId: Value(clientScopeId)),
);
}
Future<int> getDownloadOwnerCount(String globalKey) async {
return (await _validDownloadOwnerRows(globalKey)).length;
}
@@ -41,6 +143,19 @@ extension DownloadDatabaseOperations on AppDatabase {
return rows.isNotEmpty;
}
Future<List<DownloadOwnerItem>> getValidDownloadOwnersForKey(String globalKey) {
return _validDownloadOwnerRows(globalKey);
}
Future<bool> hasDownloadOwnerForCacheScope(
String globalKey, {
required String backendId,
required String clientScopeId,
}) async {
final owners = await _validDownloadOwnerRows(globalKey);
return owners.any((owner) => owner.backend == backendId && owner.clientScopeId == clientScopeId);
}
Future<List<DownloadOwnerItem>> _validDownloadOwnerRows(String globalKey, {String? excludingProfileId}) async {
final rows = await (select(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).get();
if (rows.isEmpty) return const [];
@@ -65,14 +180,34 @@ extension DownloadDatabaseOperations on AppDatabase {
/// Runs on every profile switch — validity context is computed once and
/// applied in memory instead of the per-download full-table rescan
/// `getDownloadOwnerCount` would do.
Future<void> adoptLegacyDownloadsForProfile(String profileId) async {
Future<void> adoptLegacyDownloadsForProfile(String profileId, {bool Function()? isStillActive}) async {
if (profileId.isEmpty) return;
if (isStillActive != null && !isStillActive()) return;
final rows = await select(downloadedMedia).get();
if (rows.isEmpty) return;
final owners = await select(downloadOwners).get();
final localProfileIds = (await select(profiles).get()).map((row) => row.id).toSet();
final connectionIds = (await select(connections).get()).map((row) => row.id).toSet();
final connectionRows = await select(connections).get();
final connectionIds = connectionRows.map((row) => row.id).toSet();
final connectionKindsById = {for (final row in connectionRows) row.id: row.kind};
final jellyfinIdentities = <String, ({String machineId, String? userId})>{};
final jellyfinMachineIds = <String>{};
for (final connection in connectionRows.where((row) => row.kind == 'jellyfin')) {
final identity = _jellyfinConnectionIdentity(connection);
jellyfinIdentities[connection.id] = identity;
jellyfinMachineIds.add(identity.machineId);
}
final jellyfinScopesByProfileAndMachine = <String, Map<String, Set<String>>>{};
for (final binding in await select(profileConnections).get()) {
if (binding.userIdentifier.isEmpty) continue;
final identity = jellyfinIdentities[binding.connectionId];
if (identity == null || identity.userId != null && identity.userId != binding.userIdentifier) continue;
jellyfinScopesByProfileAndMachine
.putIfAbsent(binding.profileId, () => <String, Set<String>>{})
.putIfAbsent(identity.machineId, () => <String>{})
.add('${identity.machineId}/${binding.userIdentifier}');
}
final ownedKeys = <String>{
for (final owner in owners)
if (_isValidDownloadOwner(owner, localProfileIds: localProfileIds, connectionIds: connectionIds))
@@ -80,7 +215,56 @@ extension DownloadDatabaseOperations on AppDatabase {
};
for (final row in rows) {
if (!ownedKeys.contains(row.globalKey)) {
await addDownloadOwner(profileId: profileId, globalKey: row.globalKey);
if (isStillActive != null && !isStillActive()) return;
final scopeId = row.clientScopeId;
final plexScope = PlexProfileScopeId.tryParse(scopeId ?? '');
final transferScope = PlexTransferScopeId.tryParse(scopeId ?? '');
// A scoped Plex row already identifies the Plezy profile whose token
// and cache namespace produced it. Logout first moves preserved rows
// through a sanitized transfer namespace so a new profile can adopt
// the physical file without inheriting the old profile's watch state.
if (plexScope != null && plexScope.profileId != profileId) continue;
if (plexScope != null || transferScope != null) {
await addDownloadOwner(
profileId: profileId,
globalKey: row.globalKey,
backendId: 'plex',
clientScopeId: scopeId,
);
continue;
}
final jellyfinScopes = jellyfinScopesByProfileAndMachine[profileId]?[row.serverId] ?? const <String>{};
if (jellyfinScopes.length == 1) {
final adoptingScope = jellyfinScopes.single;
await transaction(() async {
if (isStillActive != null && !isStillActive()) return;
await updateDownloadedMediaClientScope(row.globalKey, adoptingScope);
await addDownloadOwner(
profileId: profileId,
globalKey: row.globalKey,
backendId: 'jellyfin',
clientScopeId: adoptingScope,
);
});
continue;
}
// A compound non-Plex scope is a legacy Jellyfin user namespace.
// Never attach it to another profile unless that profile has exactly
// one matching Jellyfin binding. The same applies when persisted
// Jellyfin connections identify the machine but the profile has zero
// or multiple possible users.
final hasLegacyJellyfinScope = scopeId?.startsWith('${row.serverId}/') ?? false;
if (hasLegacyJellyfinScope || jellyfinMachineIds.contains(row.serverId)) continue;
final backendId = connectionKindsById[scopeId];
await addDownloadOwner(
profileId: profileId,
globalKey: row.globalKey,
backendId: backendId,
clientScopeId: scopeId,
);
}
}
}
@@ -97,20 +281,49 @@ extension DownloadDatabaseOperations on AppDatabase {
int mediaIndex = 0,
String? mediaSourceId,
}) async {
await into(downloadedMedia).insert(
DownloadedMediaCompanion.insert(
serverId: serverId,
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
type: type,
parentRatingKey: Value(parentRatingKey),
grandparentRatingKey: Value(grandparentRatingKey),
status: status,
mediaIndex: Value(mediaIndex),
mediaSourceId: Value(mediaSourceId),
),
mode: InsertMode.insertOrReplace,
await customUpdate(
'''
INSERT INTO downloaded_media (
server_id,
client_scope_id,
rating_key,
global_key,
type,
parent_rating_key,
grandparent_rating_key,
status,
media_index,
media_source_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(global_key) DO UPDATE SET
server_id = excluded.server_id,
client_scope_id = excluded.client_scope_id,
rating_key = excluded.rating_key,
type = excluded.type,
parent_rating_key = excluded.parent_rating_key,
grandparent_rating_key = excluded.grandparent_rating_key,
status = excluded.status,
progress = 0,
total_bytes = NULL,
downloaded_bytes = 0,
error_message = NULL,
retry_count = 0,
media_index = excluded.media_index,
media_source_id = excluded.media_source_id
''',
variables: [
Variable<String>(serverId),
Variable<String>(clientScopeId),
Variable<String>(ratingKey),
Variable<String>(globalKey),
Variable<String>(type),
Variable<String>(parentRatingKey),
Variable<String>(grandparentRatingKey),
Variable<int>(status),
Variable<int>(mediaIndex),
Variable<String>(mediaSourceId),
],
updates: {downloadedMedia},
);
}
@@ -132,6 +345,133 @@ extension DownloadDatabaseOperations on AppDatabase {
);
}
Future<void> updateSupplementaryQueueIntent(
String mediaGlobalKey, {
required bool downloadSubtitles,
required bool downloadArtwork,
}) async {
await (update(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).write(
DownloadQueueCompanion(downloadSubtitles: Value(downloadSubtitles), downloadArtwork: Value(downloadArtwork)),
);
}
/// Atomically admits a durable media row and its executable queue item.
///
/// Existing active, paused, and completed media rows are never rewritten.
/// Failed, cancelled, and partial attempts keep their stable row identity
/// and physical-file fields while their request and attempt state is refreshed.
Future<QueueDownloadOutcome> insertQueuedDownload({
required ServerId serverId,
String? clientScopeId,
required String ratingKey,
required String globalKey,
required String type,
String? parentRatingKey,
String? grandparentRatingKey,
int mediaIndex = 0,
String? mediaSourceId,
int priority = 0,
bool downloadSubtitles = true,
bool downloadArtwork = true,
}) {
return transaction(() async {
final admitted = await customUpdate(
'''
INSERT INTO downloaded_media (
server_id,
client_scope_id,
rating_key,
global_key,
type,
parent_rating_key,
grandparent_rating_key,
status,
media_index,
media_source_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(global_key) DO UPDATE SET
server_id = excluded.server_id,
client_scope_id = excluded.client_scope_id,
rating_key = excluded.rating_key,
type = excluded.type,
parent_rating_key = excluded.parent_rating_key,
grandparent_rating_key = excluded.grandparent_rating_key,
status = excluded.status,
progress = 0,
total_bytes = NULL,
downloaded_bytes = 0,
error_message = NULL,
retry_count = 0,
bg_task_id = NULL,
media_index = excluded.media_index,
media_source_id = excluded.media_source_id
WHERE downloaded_media.status IN (?, ?, ?)
''',
variables: [
Variable<String>(serverId),
Variable<String>(clientScopeId),
Variable<String>(ratingKey),
Variable<String>(globalKey),
Variable<String>(type),
Variable<String>(parentRatingKey),
Variable<String>(grandparentRatingKey),
Variable<int>(DownloadStatus.queued.index),
Variable<int>(mediaIndex),
Variable<String>(mediaSourceId),
Variable<int>(DownloadStatus.failed.index),
Variable<int>(DownloadStatus.cancelled.index),
Variable<int>(DownloadStatus.partial.index),
],
updates: {downloadedMedia},
);
if (admitted > 0) {
await addToQueue(
mediaGlobalKey: globalKey,
priority: priority,
downloadSubtitles: downloadSubtitles,
downloadArtwork: downloadArtwork,
);
return QueueDownloadOutcome.admitted;
}
final current = await getDownloadedMedia(globalKey);
if (current?.status == DownloadStatus.queued.index) {
await addToQueue(
mediaGlobalKey: globalKey,
priority: priority,
downloadSubtitles: downloadSubtitles,
downloadArtwork: downloadArtwork,
);
return QueueDownloadOutcome.alreadyQueued;
}
return QueueDownloadOutcome.unchanged;
});
}
/// Restores queue items omitted by legacy non-atomic queue creation.
///
/// Existing queue rows are never rewritten because they retain the original
/// priority and supplementary-download policy.
Future<int> repairMissingQueuedDownloadEntries() async {
return transaction(() async {
final queuedMedia = await (select(
downloadedMedia,
)..where((t) => t.status.equals(DownloadStatus.queued.index))).get();
if (queuedMedia.isEmpty) return 0;
final existingKeys = (await select(downloadQueue).get()).map((row) => row.mediaGlobalKey).toSet();
var repaired = 0;
for (final media in queuedMedia) {
if (existingKeys.contains(media.globalKey)) continue;
await addToQueue(mediaGlobalKey: media.globalKey);
existingKeys.add(media.globalKey);
repaired++;
}
return repaired;
});
}
/// Get next item from queue (highest priority, oldest first)
/// Only returns items that are not paused
Future<DownloadQueueItem?> getNextQueueItem() async {
@@ -151,6 +491,19 @@ extension DownloadDatabaseOperations on AppDatabase {
return result?.readTable(downloadQueue);
}
/// Completed videos whose retained queue row records unsettled
/// supplementary download intent.
Future<List<DownloadQueueItem>> getPendingSupplementaryQueueItems() async {
final query = select(
downloadQueue,
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
query
..where(downloadedMedia.status.equals(DownloadStatus.completed.index) & downloadedMedia.videoFilePath.isNotNull())
..orderBy([OrderingTerm(expression: downloadQueue.addedAt)]);
final rows = await query.get();
return rows.map((row) => row.readTable(downloadQueue)).toList(growable: false);
}
Future<void> updateDownloadStatus(String globalKey, int status) async {
await (update(
downloadedMedia,
@@ -182,6 +535,30 @@ extension DownloadDatabaseOperations on AppDatabase {
);
}
Future<void> updateDownloadSafRoot(String globalKey, String? safRootUri) async {
await (update(
downloadedMedia,
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(safRootUri: Value(safRootUri)));
}
Future<int> countDownloadsReferencingSafRoot(String safRootUri) async {
final count = downloadedMedia.id.count();
final query = selectOnly(downloadedMedia)
..addColumns([count])
..where(downloadedMedia.safRootUri.equals(safRootUri));
return (await query.map((row) => row.read(count) ?? 0).getSingle());
}
Future<Set<String>> getReferencedDownloadSafRoots() async {
final rows =
await (selectOnly(downloadedMedia)
..addColumns([downloadedMedia.safRootUri])
..where(downloadedMedia.safRootUri.isNotNull()))
.map((row) => row.read(downloadedMedia.safRootUri))
.get();
return rows.whereType<String>().toSet();
}
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
await (update(
downloadedMedia,
@@ -211,10 +588,19 @@ extension DownloadDatabaseOperations on AppDatabase {
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
}
Future<void> deleteDownload(String globalKey) async {
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
/// Removes the physical row and all dependent queue/owner state atomically,
/// returning the row's SAF root only after the transaction commits.
///
/// The caller owns persisted-grant reconciliation after this returns.
Future<String?> deleteDownload(String globalKey) async {
late String? safRootUri;
await transaction(() async {
safRootUri = (await getDownloadedMedia(globalKey))?.safRootUri;
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
});
return safRootUri;
}
Future<List<DownloadedMediaItem>> getEpisodesBySeason(
@@ -328,3 +714,23 @@ bool _isValidDownloadOwner(
if (plexHome != null) return connectionIds.contains(plexHome.accountConnectionId);
return localProfileIds.isEmpty;
}
({String machineId, String? userId}) _jellyfinConnectionIdentity(ConnectionRow connection) {
final separator = connection.id.indexOf('/');
var machineId = separator < 0 ? connection.id : connection.id.substring(0, separator);
String? userId = separator < 0 || separator == connection.id.length - 1
? null
: connection.id.substring(separator + 1);
try {
final config = jsonDecode(connection.configJson);
if (config is Map<String, dynamic>) {
final configuredMachineId = config['serverMachineId'];
final configuredUserId = config['userId'];
if (configuredMachineId is String && configuredMachineId.isNotEmpty) machineId = configuredMachineId;
if (configuredUserId is String && configuredUserId.isNotEmpty) userId = configuredUserId;
}
} on FormatException {
// Legacy rows still carry enough identity in their canonical id.
}
return (machineId: machineId, userId: userId);
}
+3
View File
@@ -53,6 +53,7 @@ class DownloadedMedia extends Table {
IntColumn get totalBytes => integer().nullable()();
IntColumn get downloadedBytes => integer().withDefault(const Constant(0))();
TextColumn get videoFilePath => text().nullable()();
TextColumn get safRootUri => text().nullable()();
TextColumn get thumbPath => text().nullable()();
IntColumn get downloadedAt => integer().nullable()();
TextColumn get errorMessage => text().nullable()();
@@ -73,6 +74,8 @@ class DownloadedMedia extends Table {
class DownloadOwners extends Table {
TextColumn get profileId => text()();
TextColumn get globalKey => text()();
TextColumn get backend => text().nullable()();
TextColumn get clientScopeId => text().nullable()();
IntColumn get createdAt => integer()();
@override
@@ -0,0 +1,562 @@
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,
);
}