fix(privacy): what the hostile instance found (ADR 0019)
Each was found by the hostile-instance test (next commit) and has a test of its own: - a write decides on its key from the records and the tree read anew: a studyset missing from a tree read before, or moved since, looked readable; - each studyset on the way up decides on its own first: its own record, private or made readable (a readable studyset the instance moved under a private one was taken as private, so a private card moved in and was opened with the other tree), or the key its own row was seen sealed with, learned from that row before a write (a topic the instance moved to the top level was written readable on a device that never read it); one sealed under a key not here refuses the write (a device that never saw a studyset private, its key record withheld, wrote into it readable); - settings keep the newest versions seen: a row older than any of them, or than another read with it, isn't read, so withholding a newer row and handing back an older one rolls nothing back; Count me folds off where any row says off; - the upkeep reads only readable tags and settings, and a PDF read by its id never refuses as a write would; - two queries passed nullable ids to `id IN`, which Serverpod refuses at run time: a path's step revisions and answers couldn't be read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
bb44a7d65d
commit
f658cb13da
8 changed files with 423 additions and 117 deletions
|
|
@ -158,9 +158,80 @@ class Privacy(
|
||||||
/// readable one, or a studyset that isn't there.
|
/// readable one, or a studyset that isn't there.
|
||||||
@override
|
@override
|
||||||
Future<String?> keyOfStudyset(String studysetId) async {
|
Future<String?> keyOfStudyset(String studysetId) async {
|
||||||
|
// The records and the tree a sync brought are known before any key is
|
||||||
|
// decided (the final reading of 22 Oct): a studyset missing from a tree
|
||||||
|
// read before, or moved since, would look readable.
|
||||||
|
await keys.refresh();
|
||||||
await _idle(studysetId);
|
await _idle(studysetId);
|
||||||
_refuseWhileDamaged();
|
_refuseWhileDamaged();
|
||||||
return treeKeyIn(await _structure(), studysetId);
|
forgetTrees();
|
||||||
|
final parents = await _structure();
|
||||||
|
final locked = await _learnStudysets(parents, studysetId);
|
||||||
|
// A studyset on the way up sealed under a key that isn't here is
|
||||||
|
// private, whatever the records withheld say: nothing is written into
|
||||||
|
// it readable (the final reading of 22 Oct).
|
||||||
|
final seen = <String>{};
|
||||||
|
for (
|
||||||
|
String? current = studysetId;
|
||||||
|
current != null && seen.add(current);
|
||||||
|
current = parents[current]
|
||||||
|
) {
|
||||||
|
if (_decided(current) case (:final keyId)) {
|
||||||
|
return keyId;
|
||||||
|
}
|
||||||
|
if (locked.contains(current)) {
|
||||||
|
throw const PrivacyRefused(PrivacyRefusal.keysMissing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return treeKeyIn(parents, studysetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Learns the keys of [studysetId] and the studysets above it from their
|
||||||
|
/// own rows, where nothing is known of them yet (the final reading of 22
|
||||||
|
/// Oct): `parentId` is the instance's to change, so a topic it moves to
|
||||||
|
/// the top level must still be known private by its own row, sealed and
|
||||||
|
/// bound to it, before a write decides on a key. Returns those whose row
|
||||||
|
/// is sealed under a key not open here.
|
||||||
|
Future<Set<String>> _learnStudysets(
|
||||||
|
Map<String, String?> parents,
|
||||||
|
String studysetId,
|
||||||
|
) async {
|
||||||
|
final unknown = <String>{};
|
||||||
|
final seen = <String>{};
|
||||||
|
for (
|
||||||
|
String? current = studysetId;
|
||||||
|
current != null && seen.add(current);
|
||||||
|
current = parents[current]
|
||||||
|
) {
|
||||||
|
if (ledger.factOf(current) == null &&
|
||||||
|
ledger.keyOfItem('studyset:$current') == null) {
|
||||||
|
unknown.add(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (unknown.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
final locked = <String>{};
|
||||||
|
for (final row in await protocol.Studyset.db.find(
|
||||||
|
_db,
|
||||||
|
where: (t) =>
|
||||||
|
t.id.inSet(<protocol.UuidValue>{
|
||||||
|
for (final id in unknown) _uuid(id),
|
||||||
|
}) &
|
||||||
|
t.keyId.notEquals(null),
|
||||||
|
)) {
|
||||||
|
if (!keys.canOpen(row.keyId!)) {
|
||||||
|
locked.add(row.id!.uuid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_learnItem('studyset:${row.id!.uuid}', 'studyset', '', [
|
||||||
|
(
|
||||||
|
id: row.id!.uuid,
|
||||||
|
e: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return locked;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [keyOfStudyset] for something about to be written into
|
/// [keyOfStudyset] for something about to be written into
|
||||||
|
|
@ -226,7 +297,6 @@ class Privacy(
|
||||||
/// In a cycle the studysets below it come first, then its members by id,
|
/// In a cycle the studysets below it come first, then its members by id,
|
||||||
/// so every member finds the same.
|
/// so every member finds the same.
|
||||||
String? _nearestPrivate(Map<String, String?> parents, String studysetId) {
|
String? _nearestPrivate(Map<String, String?> parents, String studysetId) {
|
||||||
String? keyOf(String id) => _privateKeyOf(id) ?? _itemKey('studyset:$id');
|
|
||||||
final path = <String>[];
|
final path = <String>[];
|
||||||
final seen = <String, int>{};
|
final seen = <String, int>{};
|
||||||
String? current = studysetId;
|
String? current = studysetId;
|
||||||
|
|
@ -235,7 +305,7 @@ class Privacy(
|
||||||
final below = path.sublist(0, seen[current]);
|
final below = path.sublist(0, seen[current]);
|
||||||
final cycle = path.sublist(seen[current]!)..sort();
|
final cycle = path.sublist(seen[current]!)..sort();
|
||||||
for (final id in [...below, ...cycle]) {
|
for (final id in [...below, ...cycle]) {
|
||||||
if (keyOf(id) case final keyId?) {
|
if (_decided(id) case (:final keyId)) {
|
||||||
return keyId;
|
return keyId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +313,7 @@ class Privacy(
|
||||||
}
|
}
|
||||||
seen[current] = path.length;
|
seen[current] = path.length;
|
||||||
path.add(current);
|
path.add(current);
|
||||||
if (keyOf(current) case final keyId?) {
|
if (_decided(current) case (:final keyId)) {
|
||||||
return keyId;
|
return keyId;
|
||||||
}
|
}
|
||||||
if (!parents.containsKey(current)) {
|
if (!parents.containsKey(current)) {
|
||||||
|
|
@ -258,6 +328,22 @@ class Privacy(
|
||||||
/// where it was made readable, or nothing says.
|
/// where it was made readable, or nothing says.
|
||||||
String? _privateKeyOf(String studysetId) => ledger.factOf(studysetId)?.keyId;
|
String? _privateKeyOf(String studysetId) => ledger.factOf(studysetId)?.keyId;
|
||||||
|
|
||||||
|
/// What decides [studysetId]'s privacy on its own, before any studyset
|
||||||
|
/// above it: the latest signed record about it (private with its key, or
|
||||||
|
/// made readable: `keyId` null), else the key its own row was last seen
|
||||||
|
/// sealed with; null where neither does, and its tree's decides. A
|
||||||
|
/// studyset made readable by its own record stays readable wherever the
|
||||||
|
/// instance moves it (the final reading of 22 Oct).
|
||||||
|
({String? keyId})? _decided(String studysetId) {
|
||||||
|
if (ledger.factOf(studysetId) case final fact?) {
|
||||||
|
return (keyId: fact.keyId);
|
||||||
|
}
|
||||||
|
if (_itemKey('studyset:$studysetId') case final keyId?) {
|
||||||
|
return (keyId: keyId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// The key [item] was last seen sealed with, unless a later signed record
|
/// The key [item] was last seen sealed with, unless a later signed record
|
||||||
/// made the tree of that key readable, which releases it.
|
/// made the tree of that key readable, which releases it.
|
||||||
String? _itemKey(String item) {
|
String? _itemKey(String item) {
|
||||||
|
|
@ -485,6 +571,7 @@ class Privacy(
|
||||||
/// [PrivacyRefused] on a device without the keys of an account that has
|
/// [PrivacyRefused] on a device without the keys of an account that has
|
||||||
/// them.
|
/// them.
|
||||||
Future<String?> keyOfNewTopLevel(String studysetId) async {
|
Future<String?> keyOfNewTopLevel(String studysetId) async {
|
||||||
|
await keys.refresh();
|
||||||
if (keys.canEncrypt) {
|
if (keys.canEncrypt) {
|
||||||
return await keys.newStudysetKey(studysetId);
|
return await keys.newStudysetKey(studysetId);
|
||||||
}
|
}
|
||||||
|
|
@ -499,6 +586,7 @@ class Privacy(
|
||||||
/// [PrivacyRefused] on a device without the keys of an account that has
|
/// [PrivacyRefused] on a device without the keys of an account that has
|
||||||
/// them: tags and settings are read-only there.
|
/// them: tags and settings are read-only there.
|
||||||
Future<String?> personalKey() async {
|
Future<String?> personalKey() async {
|
||||||
|
await keys.refresh();
|
||||||
if (keys.canEncrypt) {
|
if (keys.canEncrypt) {
|
||||||
return await keys.personalKey();
|
return await keys.personalKey();
|
||||||
}
|
}
|
||||||
|
|
@ -513,28 +601,57 @@ class Privacy(
|
||||||
/// payload (the short reading of 22 Oct).
|
/// payload (the short reading of 22 Oct).
|
||||||
static final _versionMark = utf8.encode('HLKV');
|
static final _versionMark = utf8.encode('HLKV');
|
||||||
|
|
||||||
/// The sealed settings row [rowId]'s [payload] (opened) without its
|
/// The sealed settings [rows] (opened, by their ids) without their
|
||||||
/// version vector; null for one that isn't read (the final reading of 22
|
/// version vectors, leaving out those that aren't read (the final reading
|
||||||
/// Oct):
|
/// of 22 Oct):
|
||||||
/// - a row this device folded away, whatever is handed back;
|
/// - a row this device folded away, whatever is handed back;
|
||||||
/// - a rollback of a row seen before: every count at most what was seen
|
/// - a row older than any version this device has seen of the person's
|
||||||
/// of it, and one lower;
|
/// settings, or than another row read with it (its own earlier one, a
|
||||||
/// - a row never seen here that brings no news: every count at most what
|
/// row written over, or one folded away elsewhere), so withholding a
|
||||||
/// was seen of the person's settings as a whole, so a row folded away on
|
/// newer row and handing back an older one rolls nothing back;
|
||||||
/// another device can't come back;
|
|
||||||
/// - one without a vector (sealed before settings had them, or under the
|
/// - one without a vector (sealed before settings had them, or under the
|
||||||
/// short reading's counter, `HLKS`): dropped, as nothing sealed so was
|
/// short reading's counter, `HLKS`): dropped, as nothing sealed so was
|
||||||
/// released.
|
/// released.
|
||||||
///
|
///
|
||||||
/// A row with news from any device is taken, so two devices' changes at
|
/// A row with news from any device is taken, so two devices' changes at
|
||||||
/// once both count. A blank one is as it is.
|
/// once both count. A blank one is as it is.
|
||||||
ByteData? settingsWithoutCounter(String rowId, ByteData payload) {
|
Map<String, ByteData> settingsWithoutCounters(Map<String, ByteData> rows) {
|
||||||
if (payload.lengthInBytes == 0) {
|
final read = <String, ({Map<String, int> vector, ByteData payload})>{};
|
||||||
return payload;
|
final blank = <String, ByteData>{};
|
||||||
|
for (final MapEntry(key: rowId, value: payload) in rows.entries) {
|
||||||
|
if (payload.lengthInBytes == 0) {
|
||||||
|
blank[rowId] = payload;
|
||||||
|
} else if (!ledger.foldedSettings(rowId)) {
|
||||||
|
if (_versioned(payload) case final versioned?) {
|
||||||
|
read[rowId] = versioned;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (ledger.foldedSettings(rowId)) {
|
final newest = [
|
||||||
return null;
|
...ledger.settingsFrontier.values,
|
||||||
|
for (final row in read.values) row.vector,
|
||||||
|
];
|
||||||
|
final kept = {
|
||||||
|
for (final MapEntry(key: rowId, value: row) in read.entries)
|
||||||
|
if (!newest.any(
|
||||||
|
(seen) => PrivacyLedger.isOlderVersion(row.vector, than: seen),
|
||||||
|
))
|
||||||
|
rowId: row,
|
||||||
|
};
|
||||||
|
for (final MapEntry(key: rowId, value: row) in kept.entries) {
|
||||||
|
ledger.sawSettingsVersion(rowId, row.vector);
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
...blank,
|
||||||
|
for (final MapEntry(key: rowId, value: row) in kept.entries)
|
||||||
|
rowId: row.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [payload]'s version vector and the payload after it; null without one.
|
||||||
|
({Map<String, int> vector, ByteData payload})? _versioned(
|
||||||
|
ByteData payload,
|
||||||
|
) {
|
||||||
final bytes = Uint8List.sublistView(payload);
|
final bytes = Uint8List.sublistView(payload);
|
||||||
final mark = _versionMark.length;
|
final mark = _versionMark.length;
|
||||||
if (bytes.length < mark + 2 || !_startsWith(bytes, _versionMark)) {
|
if (bytes.length < mark + 2 || !_startsWith(bytes, _versionMark)) {
|
||||||
|
|
@ -544,48 +661,39 @@ class Privacy(
|
||||||
if (bytes.length < mark + 2 + length) {
|
if (bytes.length < mark + 2 + length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final Map<String, int> vector;
|
|
||||||
try {
|
try {
|
||||||
final json = jsonDecode(
|
final json = jsonDecode(
|
||||||
utf8.decode(Uint8List.sublistView(bytes, mark + 2, mark + 2 + length)),
|
utf8.decode(Uint8List.sublistView(bytes, mark + 2, mark + 2 + length)),
|
||||||
);
|
);
|
||||||
vector = {
|
return (
|
||||||
if (json case final Map<String, Object?> map)
|
vector: {
|
||||||
for (final MapEntry(:key, :value) in map.entries)
|
if (json case final Map<String, Object?> map)
|
||||||
if (value case final int count) key: count,
|
for (final MapEntry(:key, :value) in map.entries)
|
||||||
};
|
if (value case final int count) key: count,
|
||||||
|
},
|
||||||
|
payload: ByteData.sublistView(bytes, mark + 2 + length),
|
||||||
|
);
|
||||||
} on FormatException {
|
} on FormatException {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (ledger.sawSettingsRow(rowId)) {
|
|
||||||
final seen = ledger.settingsVersionOf(rowId);
|
|
||||||
final devices = {...seen.keys, ...vector.keys};
|
|
||||||
final older =
|
|
||||||
devices.every((d) => (vector[d] ?? 0) <= (seen[d] ?? 0)) &&
|
|
||||||
devices.any((d) => (vector[d] ?? 0) < (seen[d] ?? 0));
|
|
||||||
if (older) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final person = ledger.settingsPersonVersion;
|
|
||||||
final news = vector.entries.any((e) => e.value > (person[e.key] ?? 0));
|
|
||||||
if (!news) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ledger.sawSettingsVersion(rowId, vector);
|
|
||||||
return ByteData.sublistView(bytes, mark + 2 + length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The settings row [rowId]'s [payload] with this device's next version in
|
/// The settings row [rowId]'s [payload] with this device's next version in
|
||||||
/// front, to be sealed: everything seen of the person's settings, and one
|
/// front, to be sealed: newer than every version seen, so it stands for
|
||||||
/// more write of this device's, so it dominates what it replaces.
|
/// all it folds and writes over.
|
||||||
ByteData settingsWithCounter(String rowId, ByteData payload) {
|
ByteData settingsWithCounter(String rowId, ByteData payload) {
|
||||||
if (payload.lengthInBytes == 0) {
|
if (payload.lengthInBytes == 0) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
final device = ledger.deviceId;
|
final device = ledger.deviceId;
|
||||||
final vector = {...ledger.settingsPersonVersion};
|
final vector = <String, int>{};
|
||||||
|
for (final seen in ledger.settingsFrontier.values) {
|
||||||
|
for (final MapEntry(key: d, value: count) in seen.entries) {
|
||||||
|
if (count > (vector[d] ?? 0)) {
|
||||||
|
vector[d] = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
vector[device] = (vector[device] ?? 0) + 1;
|
vector[device] = (vector[device] ?? 0) + 1;
|
||||||
ledger.sawSettingsVersion(rowId, vector);
|
ledger.sawSettingsVersion(rowId, vector);
|
||||||
final json = utf8.encode(jsonEncode(vector));
|
final json = utf8.encode(jsonEncode(vector));
|
||||||
|
|
@ -700,7 +808,9 @@ class Privacy(
|
||||||
_db,
|
_db,
|
||||||
where: stepIds == null
|
where: stepIds == null
|
||||||
? null
|
? null
|
||||||
: (t) => t.id.inSet({for (final id in stepIds) _uuid(id)}),
|
: (t) => t.id.inSet(<protocol.UuidValue>{
|
||||||
|
for (final id in stepIds) _uuid(id),
|
||||||
|
}),
|
||||||
))
|
))
|
||||||
step.id!.uuid: step.pathId.uuid,
|
step.id!.uuid: step.pathId.uuid,
|
||||||
};
|
};
|
||||||
|
|
@ -1017,7 +1127,8 @@ class Privacy(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [studysetId]'s tree without the studysets inside it that a signed
|
/// [studysetId]'s tree without the studysets inside it that a signed
|
||||||
/// record makes private on their own: those keep their keys.
|
/// record of their own makes private or readable: those keep theirs (the
|
||||||
|
/// final reading of 22 Oct).
|
||||||
Future<Set<String>> scopeOf(String studysetId) async {
|
Future<Set<String>> scopeOf(String studysetId) async {
|
||||||
final parents = await _structure();
|
final parents = await _structure();
|
||||||
final children = <String, List<String>>{};
|
final children = <String, List<String>>{};
|
||||||
|
|
@ -1030,7 +1141,7 @@ class Privacy(
|
||||||
final queue = [studysetId];
|
final queue = [studysetId];
|
||||||
while (queue.isNotEmpty) {
|
while (queue.isNotEmpty) {
|
||||||
final id = queue.removeLast();
|
final id = queue.removeLast();
|
||||||
if (id != studysetId && _privateKeyOf(id) != null) {
|
if (id != studysetId && ledger.factOf(id) != null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (scope.add(id)) {
|
if (scope.add(id)) {
|
||||||
|
|
@ -2055,8 +2166,13 @@ class Privacy(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var wrote = false;
|
var wrote = false;
|
||||||
for (final tag in await protocol.Tag.db.find(_db)) {
|
// Only the readable ones are read: the upkeep after every merge costs
|
||||||
if (tag.keyId != null || tag.payload.lengthInBytes == 0) {
|
// what the merge brought.
|
||||||
|
for (final tag in await protocol.Tag.db.find(
|
||||||
|
_db,
|
||||||
|
where: (t) => t.keyId.equals(null),
|
||||||
|
)) {
|
||||||
|
if (tag.payload.lengthInBytes == 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final opened = open(
|
final opened = open(
|
||||||
|
|
@ -2082,8 +2198,11 @@ class Privacy(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (final settings in await protocol.PersonSettings.db.find(_db)) {
|
for (final settings in await protocol.PersonSettings.db.find(
|
||||||
if (settings.keyId != null || settings.payload.lengthInBytes == 0) {
|
_db,
|
||||||
|
where: (t) => t.keyId.equals(null),
|
||||||
|
)) {
|
||||||
|
if (settings.payload.lengthInBytes == 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final opened = open(
|
final opened = open(
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,7 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
||||||
final _items = <String, String>{};
|
final _items = <String, String>{};
|
||||||
final _pending = <PendingRewrite>[];
|
final _pending = <PendingRewrite>[];
|
||||||
var _personalSealed = false;
|
var _personalSealed = false;
|
||||||
final _settingsVersions = <String, Map<String, int>>{};
|
final _settingsFrontier = <String, Map<String, int>>{};
|
||||||
final _settingsPerson = <String, int>{};
|
|
||||||
final _foldedSettings = <String>{};
|
final _foldedSettings = <String>{};
|
||||||
String? _deviceId;
|
String? _deviceId;
|
||||||
bool? _registerHasKeys;
|
bool? _registerHasKeys;
|
||||||
|
|
@ -150,16 +149,13 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
||||||
for (final MapEntry(:key, :value) in vector.entries)
|
for (final MapEntry(:key, :value) in vector.entries)
|
||||||
if (value case final int counter) key: counter,
|
if (value case final int counter) key: counter,
|
||||||
};
|
};
|
||||||
if (kept['settingsVersions'] case final Map<String, Object?> rows) {
|
if (kept['settingsFrontier'] case final Map<String, Object?> rows) {
|
||||||
for (final MapEntry(key: row, :value) in rows.entries) {
|
for (final MapEntry(key: row, :value) in rows.entries) {
|
||||||
if (value case final Map<String, Object?> vector) {
|
if (value case final Map<String, Object?> vector) {
|
||||||
_settingsVersions[row] = vectorOf(vector);
|
_settingsFrontier[row] = vectorOf(vector);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (kept['settingsPerson'] case final Map<String, Object?> vector) {
|
|
||||||
_settingsPerson.addAll(vectorOf(vector));
|
|
||||||
}
|
|
||||||
if (kept['foldedSettings'] case final List<Object?> folded) {
|
if (kept['foldedSettings'] case final List<Object?> folded) {
|
||||||
_foldedSettings.addAll(folded.whereType<String>());
|
_foldedSettings.addAll(folded.whereType<String>());
|
||||||
}
|
}
|
||||||
|
|
@ -243,37 +239,42 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
||||||
return _deviceId!;
|
return _deviceId!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The version vector seen of the settings row [rowId] (each device's
|
/// The newest versions of the person's settings this device has seen (the
|
||||||
/// count of writes to the person's settings), empty for one never seen.
|
/// final reading of 22 Oct): each row's vector (each device's count of
|
||||||
Map<String, int> settingsVersionOf(String rowId) =>
|
/// writes) that no other seen vector is newer than. Two rows written at
|
||||||
Map.unmodifiable(_settingsVersions[rowId] ?? const {});
|
/// once on two devices are both here; a row folded into another, or
|
||||||
|
/// written over, is not.
|
||||||
|
Map<String, Map<String, int>> get settingsFrontier => {
|
||||||
|
for (final MapEntry(:key, :value) in _settingsFrontier.entries)
|
||||||
|
key: Map.unmodifiable(value),
|
||||||
|
};
|
||||||
|
|
||||||
/// Whether the settings row [rowId] was ever seen here.
|
/// Keeps that the settings row [rowId] was seen at [vector]: it joins the
|
||||||
bool sawSettingsRow(String rowId) => _settingsVersions.containsKey(rowId);
|
/// newest, and every vector it is newer than leaves them.
|
||||||
|
|
||||||
/// The highest count of each device seen across all the person's settings
|
|
||||||
/// rows (the final reading of 22 Oct): one row's news is everyone's.
|
|
||||||
Map<String, int> get settingsPersonVersion =>
|
|
||||||
Map.unmodifiable(_settingsPerson);
|
|
||||||
|
|
||||||
/// Keeps what [vector] adds to what was seen of the settings row [rowId],
|
|
||||||
/// and of the person's settings as a whole: each device's highest count.
|
|
||||||
void sawSettingsVersion(String rowId, Map<String, int> vector) {
|
void sawSettingsVersion(String rowId, Map<String, int> vector) {
|
||||||
var changed = !_settingsVersions.containsKey(rowId);
|
final own = _settingsFrontier[rowId];
|
||||||
final seen = _settingsVersions[rowId] ??= {};
|
if (own != null && _sameVector(own, vector)) {
|
||||||
for (final MapEntry(key: device, value: count) in vector.entries) {
|
return;
|
||||||
if (count > (seen[device] ?? 0)) {
|
|
||||||
seen[device] = count;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (count > (_settingsPerson[device] ?? 0)) {
|
|
||||||
_settingsPerson[device] = count;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) {
|
|
||||||
_changed();
|
|
||||||
}
|
}
|
||||||
|
_settingsFrontier
|
||||||
|
..removeWhere((_, seen) => isOlderVersion(seen, than: vector))
|
||||||
|
..[rowId] = {...vector};
|
||||||
|
_changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [vector] is older than [than]: no count higher, and one lower.
|
||||||
|
static bool isOlderVersion(
|
||||||
|
Map<String, int> vector, {
|
||||||
|
required Map<String, int> than,
|
||||||
|
}) {
|
||||||
|
final devices = {...vector.keys, ...than.keys};
|
||||||
|
return devices.every((d) => (vector[d] ?? 0) <= (than[d] ?? 0)) &&
|
||||||
|
devices.any((d) => (vector[d] ?? 0) < (than[d] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _sameVector(Map<String, int> a, Map<String, int> b) {
|
||||||
|
final devices = {...a.keys, ...b.keys};
|
||||||
|
return devices.every((d) => (a[d] ?? 0) == (b[d] ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the settings row [rowId] was folded away here, so never read
|
/// Whether the settings row [rowId] was folded away here, so never read
|
||||||
|
|
@ -360,8 +361,7 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
||||||
{'studyset': p.studysetId, 'key': p.keyId, 'adopt': p.adopt},
|
{'studyset': p.studysetId, 'key': p.keyId, 'adopt': p.adopt},
|
||||||
],
|
],
|
||||||
'personalSealed': _personalSealed,
|
'personalSealed': _personalSealed,
|
||||||
'settingsVersions': _settingsVersions,
|
'settingsFrontier': _settingsFrontier,
|
||||||
'settingsPerson': _settingsPerson,
|
|
||||||
'foldedSettings': [..._foldedSettings],
|
'foldedSettings': [..._foldedSettings],
|
||||||
'device': ?_deviceId,
|
'device': ?_deviceId,
|
||||||
'registerHasKeys': ?_registerHasKeys,
|
'registerHasKeys': ?_registerHasKeys,
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ class SyncLearningPathGateway(
|
||||||
: {
|
: {
|
||||||
for (final path in await protocol.LearningPath.db.find(
|
for (final path in await protocol.LearningPath.db.find(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.id.inSet({...pathOf.values}),
|
where: (t) => t.id.inSet(<protocol.UuidValue>{...pathOf.values}),
|
||||||
))
|
))
|
||||||
path.id!.uuid: path.studysetId.uuid,
|
path.id!.uuid: path.studysetId.uuid,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,11 @@ class SyncPdfSourceGateway(
|
||||||
// PDF.
|
// PDF.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
await _privacy.keys.refresh();
|
||||||
|
// Read as every list is: reading never refuses, whatever a write would.
|
||||||
|
final treeKey = await _privacy.treeKeys();
|
||||||
return switch (await protocol.PdfSource.db.findById(_db, uuid)) {
|
return switch (await protocol.PdfSource.db.findById(_db, uuid)) {
|
||||||
final source? => _row(
|
final source? => _row(source, treeKey(source.studysetId.uuid)),
|
||||||
source,
|
|
||||||
await _privacy.keyOfStudyset(source.studysetId.uuid),
|
|
||||||
),
|
|
||||||
null => null,
|
null => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,27 +19,34 @@ class SyncSettingsGateway(
|
||||||
@override
|
@override
|
||||||
Future<List<SettingsRow>> rows() async {
|
Future<List<SettingsRow>> rows() async {
|
||||||
await _privacy.keys.refresh();
|
await _privacy.keys.refresh();
|
||||||
|
final readable = <String, ByteData>{};
|
||||||
|
final sealed = <String, ByteData>{};
|
||||||
|
for (final row in await protocol.PersonSettings.db.find(_db)) {
|
||||||
|
if (_privacy.openItem(
|
||||||
|
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||||
|
table: 'person_settings',
|
||||||
|
bound: '',
|
||||||
|
id: row.id!.uuid,
|
||||||
|
item: 'settings:${row.id!.uuid}',
|
||||||
|
treeKey: null,
|
||||||
|
protected: _privacy.personalProtected,
|
||||||
|
)
|
||||||
|
case Readable(:final payload)) {
|
||||||
|
(row.keyId != null ? sealed : readable)[row.id!.uuid] = payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The sealed ones as their versions allow, all read together, so one
|
||||||
|
// older than another read with it is left out (the final reading of 22
|
||||||
|
// Oct).
|
||||||
return [
|
return [
|
||||||
for (final row in await protocol.PersonSettings.db.find(_db))
|
for (final MapEntry(:key, :value) in {
|
||||||
if (_privacy.openItem(
|
...readable,
|
||||||
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
..._privacy.settingsWithoutCounters(sealed),
|
||||||
table: 'person_settings',
|
}.entries)
|
||||||
bound: '',
|
(id: key, payload: value),
|
||||||
id: row.id!.uuid,
|
|
||||||
item: 'settings:${row.id!.uuid}',
|
|
||||||
treeKey: null,
|
|
||||||
protected: _privacy.personalProtected,
|
|
||||||
)
|
|
||||||
case Readable(:final payload))
|
|
||||||
if (_counted(row.id!.uuid, sealed: row.keyId != null, payload)
|
|
||||||
case final payload?)
|
|
||||||
(id: row.id!.uuid, payload: payload),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
ByteData? _counted(String id, ByteData payload, {required bool sealed}) =>
|
|
||||||
sealed ? _privacy.settingsWithoutCounter(id, payload) : payload;
|
|
||||||
|
|
||||||
ByteData _withCounter(String id, ByteData payload, String? keyId) =>
|
ByteData _withCounter(String id, ByteData payload, String? keyId) =>
|
||||||
keyId == null ? payload : _privacy.settingsWithCounter(id, payload);
|
keyId == null ? payload : _privacy.settingsWithCounter(id, payload);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,8 +173,11 @@ final class const PersonSettings({
|
||||||
restDays: {...other.restDays, ...restDays},
|
restDays: {...other.restDays, ...restDays},
|
||||||
home: home == const HomeLayout() ? other.home : home,
|
home: home == const HomeLayout() ? other.home : home,
|
||||||
pomodoro: pomodoro == const PomodoroSettings() ? other.pomodoro : pomodoro,
|
pomodoro: pomodoro == const PomodoroSettings() ? other.pomodoro : pomodoro,
|
||||||
// Left as it comes (off) counts as none.
|
// Off where either says off (ADR 0019, the final reading of 22 Oct):
|
||||||
countLearning: countLearning || other.countLearning,
|
// an instance withholding a newer off and handing back an older on, or
|
||||||
|
// an on another device wrote before it, could otherwise switch counting
|
||||||
|
// on; the person switches it on again at worst.
|
||||||
|
countLearning: countLearning && other.countLearning,
|
||||||
privateOfferAnswered: privateOfferAnswered || other.privateOfferAnswered,
|
privateOfferAnswered: privateOfferAnswered || other.privateOfferAnswered,
|
||||||
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
|
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
|
||||||
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
|
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import 'package:halkyon_learn_flutter/src/core/privacy/data/sync_content_key_gat
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.dart';
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
|
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_learning_path_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_path_answer_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
|
||||||
|
|
@ -79,6 +81,8 @@ void main() {
|
||||||
SyncCardGateway(db, sync, const protocol.Uuid().v7(), privacy),
|
SyncCardGateway(db, sync, const protocol.Uuid().v7(), privacy),
|
||||||
SyncTagGateway(db, sync, privacy),
|
SyncTagGateway(db, sync, privacy),
|
||||||
SyncSettingsGateway(db, sync, privacy),
|
SyncSettingsGateway(db, sync, privacy),
|
||||||
|
SyncLearningPathGateway(db, sync, const protocol.Uuid().v7(), privacy),
|
||||||
|
SyncPathAnswerGateway(db, sync, privacy),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1079,14 +1083,38 @@ void main() {
|
||||||
expect([for (final r in await laptop.settings.rows()) r.id], [kept]);
|
expect([for (final r in await laptop.settings.rows()) r.id], [kept]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a settings row the instance withheld and hands back after a '
|
||||||
|
'newer one is never read again', () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final older = await laptop.settings.insert(
|
||||||
|
json({'countLearning': true}),
|
||||||
|
);
|
||||||
|
final asItWas = (await protocol.PersonSettings.db.findById(
|
||||||
|
db,
|
||||||
|
protocol.UuidValue.withValidation(older),
|
||||||
|
))!;
|
||||||
|
// The instance withholds it; the person turns Count me off, in a row
|
||||||
|
// of its own as none is there.
|
||||||
|
await protocol.PersonSettings.db.deleteRow(db, asItWas);
|
||||||
|
final newer = await laptop.settings.insert(
|
||||||
|
json({'countLearning': false}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await protocol.PersonSettings.db.insertRow(db, asItWas);
|
||||||
|
|
||||||
|
expect([for (final r in await laptop.settings.rows()) r.id], [newer]);
|
||||||
|
});
|
||||||
|
|
||||||
test('a settings row this device never saw is read only if it brings '
|
test('a settings row this device never saw is read only if it brings '
|
||||||
'news', () async {
|
'news', () async {
|
||||||
final laptop = await device(account: alice);
|
final laptop = await device(account: alice);
|
||||||
final phone = await device(account: alice);
|
|
||||||
final kept = await laptop.settings.insert(
|
final kept = await laptop.settings.insert(
|
||||||
json({'countLearning': false}),
|
json({'countLearning': false}),
|
||||||
);
|
);
|
||||||
expect(await phone.settings.rows(), hasLength(1));
|
expect(await laptop.settings.rows(), hasLength(1));
|
||||||
|
// The phone, before it heard of the laptop's, writes a row of its own;
|
||||||
|
// then folds the two, as the repository does, Count me off.
|
||||||
|
final phone = await device(account: alice);
|
||||||
final other = await phone.settings.insert(
|
final other = await phone.settings.insert(
|
||||||
json({'countLearning': true}),
|
json({'countLearning': true}),
|
||||||
);
|
);
|
||||||
|
|
@ -1094,8 +1122,6 @@ void main() {
|
||||||
db,
|
db,
|
||||||
protocol.UuidValue.withValidation(other),
|
protocol.UuidValue.withValidation(other),
|
||||||
))!;
|
))!;
|
||||||
// The phone folds it away again and turns Count me off, before the
|
|
||||||
// laptop ever saw it.
|
|
||||||
expect(await phone.settings.rows(), hasLength(2));
|
expect(await phone.settings.rows(), hasLength(2));
|
||||||
await phone.settings.updatePayload(other, redactedPayload);
|
await phone.settings.updatePayload(other, redactedPayload);
|
||||||
await phone.settings.delete(other);
|
await phone.settings.delete(other);
|
||||||
|
|
@ -1147,6 +1173,144 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
group('found by the hostile instance', () {
|
||||||
|
test('a write right after another device made the studyset is sealed '
|
||||||
|
'with its tree, though this device read nothing since', () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final phone = await device(account: alice);
|
||||||
|
final top = await privateStudyset(laptop, 'Japanese');
|
||||||
|
final topic = await laptop.studysets.insert(
|
||||||
|
studysetPayload('N3'),
|
||||||
|
DateTime.utc(2026, 10, 22),
|
||||||
|
parentId: top,
|
||||||
|
);
|
||||||
|
|
||||||
|
final path = await phone.paths.insertPath(
|
||||||
|
studysetId: topic,
|
||||||
|
position: 'a0',
|
||||||
|
payload: json({'schemaVersion': 1, 'title': 'SECRET'}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final row = (await protocol.LearningPath.db.findById(
|
||||||
|
db,
|
||||||
|
protocol.UuidValue.withValidation(path),
|
||||||
|
))!;
|
||||||
|
expect(row.keyId, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a private topic the instance moves to the top level stays '
|
||||||
|
'private on a device that never read its row', () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final top = await privateStudyset(laptop, 'Japanese');
|
||||||
|
final topic = await laptop.studysets.insert(
|
||||||
|
studysetPayload('N3'),
|
||||||
|
DateTime.utc(2026, 10, 22),
|
||||||
|
parentId: top,
|
||||||
|
);
|
||||||
|
final phone = await device(account: alice);
|
||||||
|
final row = (await protocol.Studyset.db.findById(
|
||||||
|
db,
|
||||||
|
protocol.UuidValue.withValidation(topic),
|
||||||
|
))!;
|
||||||
|
|
||||||
|
await protocol.Studyset.db.updateRow(
|
||||||
|
db,
|
||||||
|
// The generated copyWith sets a null given, its default being a
|
||||||
|
// marker the lint can't see.
|
||||||
|
// ignore: avoid_redundant_argument_values
|
||||||
|
row.copyWith(parentId: null),
|
||||||
|
);
|
||||||
|
final card = await phone.cards.insertCard(
|
||||||
|
studysetId: topic,
|
||||||
|
position: 'a0',
|
||||||
|
payload: cardPayload('SECRET'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await sealed(card), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a device that never saw a studyset private, its key record '
|
||||||
|
'withheld, writes nothing into it: its row is sealed', () async {
|
||||||
|
final phone = await device(account: alice);
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final top = await privateStudyset(laptop, 'Diary');
|
||||||
|
await protocol.ContentKey.db.deleteWhere(
|
||||||
|
db,
|
||||||
|
where: (t) => t.dataKeyId.like('s1.$top.%'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
phone.cards.insertCard(
|
||||||
|
studysetId: top,
|
||||||
|
position: 'a0',
|
||||||
|
payload: cardPayload('SECRET'),
|
||||||
|
),
|
||||||
|
throwsA(isA<PrivacyRefused>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a studyset made readable by its own record stays readable where '
|
||||||
|
"the instance moves it: a private card isn't moved in, and making "
|
||||||
|
'the other readable never opens it', () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final open = await privateStudyset(laptop, 'Kitchen German');
|
||||||
|
await laptop.studysets.setPrivate(open, private: false);
|
||||||
|
final secret = await privateStudyset(laptop, 'Diary');
|
||||||
|
final card = await laptop.cards.insertCard(
|
||||||
|
studysetId: secret,
|
||||||
|
position: 'a0',
|
||||||
|
payload: cardPayload('SECRET'),
|
||||||
|
);
|
||||||
|
final row = (await protocol.Studyset.db.findById(
|
||||||
|
db,
|
||||||
|
protocol.UuidValue.withValidation(open),
|
||||||
|
))!;
|
||||||
|
await protocol.Studyset.db.updateRow(
|
||||||
|
db,
|
||||||
|
row.copyWith(parentId: protocol.UuidValue.withValidation(secret)),
|
||||||
|
);
|
||||||
|
// As after the merge that brought it.
|
||||||
|
laptop.privacy.forgetTrees();
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
laptop.cards.updateStudyset(card, open, 'a1'),
|
||||||
|
throwsA(isA<PrivacyRefused>()),
|
||||||
|
);
|
||||||
|
expect(await laptop.privacy.keyOfStudyset(open), isNull);
|
||||||
|
expect(await sealed(card), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a path's steps and answers are read back", () async {
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final id = await privateStudyset(laptop, 'Diary');
|
||||||
|
final path = await laptop.paths.insertPath(
|
||||||
|
studysetId: id,
|
||||||
|
position: 'a0',
|
||||||
|
payload: json({'schemaVersion': 1, 'title': 'Week 1'}),
|
||||||
|
);
|
||||||
|
final step = await laptop.paths.insertStep(
|
||||||
|
pathId: path,
|
||||||
|
position: 'a0',
|
||||||
|
kind: 'read',
|
||||||
|
payload: json({'schemaVersion': 1, 'title': 'Read'}),
|
||||||
|
);
|
||||||
|
await laptop.answers.insert([
|
||||||
|
(
|
||||||
|
stepId: step.stepId,
|
||||||
|
answeredAt: DateTime.utc(2026, 10, 22),
|
||||||
|
outcome: 'done',
|
||||||
|
answer: 'mine',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await laptop.paths.revisions({step.stepId}), hasLength(1));
|
||||||
|
expect(
|
||||||
|
await laptop.answers.answers(stepIds: {step.stepId}),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('a tree stays partly readable while rows in it stay locked', () async {
|
test('a tree stays partly readable while rows in it stay locked', () async {
|
||||||
final laptop = await device(account: alice);
|
final laptop = await device(account: alice);
|
||||||
final id = await privateStudyset(laptop, 'Diary');
|
final id = await privateStudyset(laptop, 'Diary');
|
||||||
|
|
@ -1180,6 +1344,8 @@ final class const _Device(
|
||||||
final SyncCardGateway cards,
|
final SyncCardGateway cards,
|
||||||
final SyncTagGateway tags,
|
final SyncTagGateway tags,
|
||||||
final SyncSettingsGateway settings,
|
final SyncSettingsGateway settings,
|
||||||
|
final SyncLearningPathGateway paths,
|
||||||
|
final SyncPathAnswerGateway answers,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The person's files, one of which won't move while [failing].
|
/// The person's files, one of which won't move while [failing].
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,17 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Count me is off as two devices' settings fold where either says "
|
||||||
|
'off: an instance handing back an older on never switches it on (ADR '
|
||||||
|
'0019, the final reading of 22 Oct)', () {
|
||||||
|
const on = PersonSettings(countLearning: true);
|
||||||
|
const off = PersonSettings();
|
||||||
|
|
||||||
|
expect(on.foldedWith(off).countLearning, isFalse);
|
||||||
|
expect(off.foldedWith(on).countLearning, isFalse);
|
||||||
|
expect(on.foldedWith(on).countLearning, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
test('hiding the tags from Jellyfin while studying survives a round '
|
test('hiding the tags from Jellyfin while studying survives a round '
|
||||||
'trip, in the ninth schema, left out while off; on, on either '
|
'trip, in the ninth schema, left out while off; on, on either '
|
||||||
'device, it stays on as they fold', () {
|
'device, it stays on as they fold', () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue