feat(privacy): Finish takes in edits nobody signed once the person agrees (ADR 0019)

Finishing a partly readable studyset counts the edits a device without
the keys wrote readable while it was made private, by kind (flashcards,
notes, learning paths, PDFs, studysets), and asks: taken in, they are
sealed with the tree's key as the person's; left, they stay locked (the
short reading of 22 Oct, finding 11). In English and German. Tests on the
real gateways both ways, and of the dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-22 10:38:43 +02:00
commit 991bef47eb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 518 additions and 0 deletions

View file

@ -2076,6 +2076,21 @@
"publishTagsSent": "Die Namen der Tags, die mitgehen und die die Instanz erfährt: {names}", "publishTagsSent": "Die Namen der Tags, die mitgehen und die die Instanz erfährt: {names}",
"@publishTagsSent": {"placeholders": {"names": {"type": "String"}}}, "@publishTagsSent": {"placeholders": {"names": {"type": "String"}}},
"publishTagsSentNone": "Es gehen keine Tags mit.", "publishTagsSentNone": "Es gehen keine Tags mit.",
"privacyAdoptTitle": "Unsignierte Änderungen übernehmen?",
"privacyAdoptBody": "Während es privat gemacht wurde, hat ein Gerät ohne deine Schlüssel es geändert: {parts}. Übernommen, werden sie als deine versiegelt; sonst bleiben sie gesperrt, wie alles, was die Instanz geschrieben haben könnte.",
"@privacyAdoptBody": {"placeholders": {"parts": {"type": "String"}}},
"privacyAdoptTake": "Übernehmen",
"privacyAdoptLeave": "Gesperrt lassen",
"privacyEditsCards": "{count, plural, =1{1 Karteikarte} other{{count} Karteikarten}}",
"@privacyEditsCards": {"placeholders": {"count": {"type": "int"}}},
"privacyEditsNotes": "{count, plural, =1{1 Notiz} other{{count} Notizen}}",
"@privacyEditsNotes": {"placeholders": {"count": {"type": "int"}}},
"privacyEditsPaths": "{count, plural, =1{1 Änderung an Lernpfaden} other{{count} Änderungen an Lernpfaden}}",
"@privacyEditsPaths": {"placeholders": {"count": {"type": "int"}}},
"privacyEditsPdfs": "{count, plural, =1{1 PDF} other{{count} PDFs}}",
"@privacyEditsPdfs": {"placeholders": {"count": {"type": "int"}}},
"privacyEditsStudysets": "{count, plural, =1{1 Lernset} other{{count} Lernsets}}",
"@privacyEditsStudysets": {"placeholders": {"count": {"type": "int"}}},
"studysetLockedTitle": "Gesperrt", "studysetLockedTitle": "Gesperrt",
"studysetLockedBody": "Dieses Lernset ist privat, und sein Schlüssel öffnet sich auf diesem Gerät nicht: Es wurde mit Schlüsseln verschlüsselt, die dein Konto nicht mehr hat. Es lässt sich nur noch löschen.", "studysetLockedBody": "Dieses Lernset ist privat, und sein Schlüssel öffnet sich auf diesem Gerät nicht: Es wurde mit Schlüsseln verschlüsselt, die dein Konto nicht mehr hat. Es lässt sich nur noch löschen.",
"studysetVisibilityPartly": "Privat, teils lesbar", "studysetVisibilityPartly": "Privat, teils lesbar",

View file

@ -2442,6 +2442,21 @@
"publishTagsSent": "The names of the tags that go with it, which the instance learns: {names}", "publishTagsSent": "The names of the tags that go with it, which the instance learns: {names}",
"@publishTagsSent": {"placeholders": {"names": {"type": "String"}}}, "@publishTagsSent": {"placeholders": {"names": {"type": "String"}}},
"publishTagsSentNone": "No tags go with it.", "publishTagsSentNone": "No tags go with it.",
"privacyAdoptTitle": "Take in edits nobody signed?",
"privacyAdoptBody": "While it was made private, a device without your keys changed it: {parts}. Taken in, they are sealed as yours; left, they stay locked, as anything the instance could have written.",
"@privacyAdoptBody": {"placeholders": {"parts": {"type": "String"}}},
"privacyAdoptTake": "Take them in",
"privacyAdoptLeave": "Leave them locked",
"privacyEditsCards": "{count, plural, =1{1 flashcard} other{{count} flashcards}}",
"@privacyEditsCards": {"placeholders": {"count": {"type": "int"}}},
"privacyEditsNotes": "{count, plural, =1{1 note} other{{count} notes}}",
"@privacyEditsNotes": {"placeholders": {"count": {"type": "int"}}},
"privacyEditsPaths": "{count, plural, =1{1 change to learning paths} other{{count} changes to learning paths}}",
"@privacyEditsPaths": {"placeholders": {"count": {"type": "int"}}},
"privacyEditsPdfs": "{count, plural, =1{1 PDF} other{{count} PDFs}}",
"@privacyEditsPdfs": {"placeholders": {"count": {"type": "int"}}},
"privacyEditsStudysets": "{count, plural, =1{1 studyset} other{{count} studysets}}",
"@privacyEditsStudysets": {"placeholders": {"count": {"type": "int"}}},
"studysetLockedTitle": "Locked", "studysetLockedTitle": "Locked",
"studysetLockedBody": "This studyset is private, and its key doesn't open on this device: it was encrypted with keys your account no longer has. It can only be deleted.", "studysetLockedBody": "This studyset is private, and its key doesn't open on this device: it was encrypted with keys your account no longer has. It can only be deleted.",
"studysetVisibilityPartly": "Private, partly readable", "studysetVisibilityPartly": "Private, partly readable",

View file

@ -12237,6 +12237,60 @@ abstract class AppLocalizations {
/// **'No tags go with it.'** /// **'No tags go with it.'**
String get publishTagsSentNone; String get publishTagsSentNone;
/// No description provided for @privacyAdoptTitle.
///
/// In en, this message translates to:
/// **'Take in edits nobody signed?'**
String get privacyAdoptTitle;
/// No description provided for @privacyAdoptBody.
///
/// In en, this message translates to:
/// **'While it was made private, a device without your keys changed it: {parts}. Taken in, they are sealed as yours; left, they stay locked, as anything the instance could have written.'**
String privacyAdoptBody(String parts);
/// No description provided for @privacyAdoptTake.
///
/// In en, this message translates to:
/// **'Take them in'**
String get privacyAdoptTake;
/// No description provided for @privacyAdoptLeave.
///
/// In en, this message translates to:
/// **'Leave them locked'**
String get privacyAdoptLeave;
/// No description provided for @privacyEditsCards.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 flashcard} other{{count} flashcards}}'**
String privacyEditsCards(int count);
/// No description provided for @privacyEditsNotes.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 note} other{{count} notes}}'**
String privacyEditsNotes(int count);
/// No description provided for @privacyEditsPaths.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 change to learning paths} other{{count} changes to learning paths}}'**
String privacyEditsPaths(int count);
/// No description provided for @privacyEditsPdfs.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 PDF} other{{count} PDFs}}'**
String privacyEditsPdfs(int count);
/// No description provided for @privacyEditsStudysets.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 studyset} other{{count} studysets}}'**
String privacyEditsStudysets(int count);
/// No description provided for @studysetLockedTitle. /// No description provided for @studysetLockedTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -7759,6 +7759,75 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get publishTagsSentNone => 'Es gehen keine Tags mit.'; String get publishTagsSentNone => 'Es gehen keine Tags mit.';
@override
String get privacyAdoptTitle => 'Unsignierte Änderungen übernehmen?';
@override
String privacyAdoptBody(String parts) {
return 'Während es privat gemacht wurde, hat ein Gerät ohne deine Schlüssel es geändert: $parts. Übernommen, werden sie als deine versiegelt; sonst bleiben sie gesperrt, wie alles, was die Instanz geschrieben haben könnte.';
}
@override
String get privacyAdoptTake => 'Übernehmen';
@override
String get privacyAdoptLeave => 'Gesperrt lassen';
@override
String privacyEditsCards(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten',
one: '1 Karteikarte',
);
return '$_temp0';
}
@override
String privacyEditsNotes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Notizen',
one: '1 Notiz',
);
return '$_temp0';
}
@override
String privacyEditsPaths(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Änderungen an Lernpfaden',
one: '1 Änderung an Lernpfaden',
);
return '$_temp0';
}
@override
String privacyEditsPdfs(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count PDFs',
one: '1 PDF',
);
return '$_temp0';
}
@override
String privacyEditsStudysets(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Lernsets',
one: '1 Lernset',
);
return '$_temp0';
}
@override @override
String get studysetLockedTitle => 'Gesperrt'; String get studysetLockedTitle => 'Gesperrt';

View file

@ -7686,6 +7686,75 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get publishTagsSentNone => 'No tags go with it.'; String get publishTagsSentNone => 'No tags go with it.';
@override
String get privacyAdoptTitle => 'Take in edits nobody signed?';
@override
String privacyAdoptBody(String parts) {
return 'While it was made private, a device without your keys changed it: $parts. Taken in, they are sealed as yours; left, they stay locked, as anything the instance could have written.';
}
@override
String get privacyAdoptTake => 'Take them in';
@override
String get privacyAdoptLeave => 'Leave them locked';
@override
String privacyEditsCards(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count flashcards',
one: '1 flashcard',
);
return '$_temp0';
}
@override
String privacyEditsNotes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count notes',
one: '1 note',
);
return '$_temp0';
}
@override
String privacyEditsPaths(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count changes to learning paths',
one: '1 change to learning paths',
);
return '$_temp0';
}
@override
String privacyEditsPdfs(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count PDFs',
one: '1 PDF',
);
return '$_temp0';
}
@override
String privacyEditsStudysets(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count studysets',
one: '1 studyset',
);
return '$_temp0';
}
@override @override
String get studysetLockedTitle => 'Locked'; String get studysetLockedTitle => 'Locked';

View file

@ -508,6 +508,119 @@ class Privacy(
return of; return of;
} }
/// The edits in [studysetId]'s private scope written readable without the
/// account's signature (a device without the keys, while it was made
/// private), by kind (`cards`, `notes`, `paths`, `pdfs`, `studysets`):
/// locked until the person takes them in ([adoptUnsigned]).
Future<Map<String, int>> unsignedIn(String studysetId) async {
final ids = {for (final id in await scopeOf(studysetId)) _uuid(id)};
final counts = <String, int>{};
void count(String kind, String table, String id, Envelope e) {
if (e.keyId != null || e.payload.lengthInBytes == 0) {
return;
}
if (open(e, table: table, id: id) case Readable(trusted: false)) {
counts[kind] = (counts[kind] ?? 0) + 1;
}
}
for (final r in await protocol.Studyset.db.find(
_db,
where: (t) => t.id.inSet(ids) & t.keyId.equals(null),
)) {
count('studysets', 'studyset', r.id!.uuid, (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
}
final cards = {
for (final card in await protocol.Card.db.find(
_db,
where: (t) => t.studysetId.inSet(ids),
))
card.id!,
};
for (final r in await protocol.CardRevision.db.find(
_db,
where: (t) => t.cardId.inSet(cards) & t.keyId.equals(null),
)) {
count('cards', 'card_revision', r.id!.uuid, (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
}
final notes = {
for (final note in await protocol.Note.db.find(
_db,
where: (t) => t.studysetId.inSet(ids),
))
note.id!,
};
for (final r in await protocol.NoteRevision.db.find(
_db,
where: (t) => t.noteId.inSet(notes) & t.keyId.equals(null),
)) {
count('notes', 'note_revision', r.id!.uuid, (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
}
final paths = await protocol.LearningPath.db.find(
_db,
where: (t) => t.studysetId.inSet(ids),
);
for (final r in paths) {
count('paths', 'learning_path', r.id!.uuid, (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
}
final steps = {
for (final step in await protocol.LearningPathStep.db.find(
_db,
where: (t) => t.pathId.inSet({for (final path in paths) path.id!}),
))
step.id!,
};
for (final r in await protocol.LearningPathStepRevision.db.find(
_db,
where: (t) => t.stepId.inSet(steps) & t.keyId.equals(null),
)) {
count('paths', 'learning_path_step_revision', r.id!.uuid, (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
}
for (final r in await protocol.PdfSource.db.find(
_db,
where: (t) => t.studysetId.inSet(ids) & t.keyId.equals(null),
)) {
count('pdfs', 'pdf_source', r.id!.uuid, (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
}
return counts;
}
/// Takes in the edits [unsignedIn] counts, once the person confirmed: they
/// are sealed with the tree's key as theirs. Returns how many rows or
/// files are still left.
Future<int> adoptUnsigned(String studysetId) async {
final keyId = _privateKeyOf(studysetId);
if (keyId == null || !keys.canEncrypt) {
throw const PrivacyRefused(PrivacyRefusal.keysMissing);
}
_unmovable.clear();
return await rekeyTree(studysetId, keyId, adopt: true);
}
/// Whether anything in the studysets [ids] is sealed: a studyset, a card's /// Whether anything in the studysets [ids] is sealed: a studyset, a card's
/// or a note's revision, a path, a step's revision or a PDF. Publishing /// or a note's revision, a path, a step's revision or a PDF. Publishing
/// refuses on the device then, before any tag's name goes out (the short /// refuses on the device then, before any tag's name goes out (the short

View file

@ -52,6 +52,13 @@ abstract interface class StudysetGateway() {
/// account's keys (ADR 0019, D8). /// account's keys (ADR 0019, D8).
bool get canMakePrivate; bool get canMakePrivate;
/// The edits in the private studyset [id] a device without the keys wrote
/// readable, by kind; locked until taken in.
Future<Map<String, int>> unsignedEditsOf(String id);
/// Seals the edits [unsignedEditsOf] counts as the person's.
Future<int> adoptUnsignedEdits(String id);
/// Whether the account has keys this device doesn't hold: it writes /// Whether the account has keys this device doesn't hold: it writes
/// nothing here that would be private, new studysets among them (the /// nothing here that would be private, new studysets among them (the
/// reading of 22 Oct). /// reading of 22 Oct).

View file

@ -131,6 +131,18 @@ class StudysetRepository(final StudysetGateway _gateway) {
/// account's keys (ADR 0019, D8). /// account's keys (ADR 0019, D8).
bool get canMakePrivate => _gateway.canMakePrivate; bool get canMakePrivate => _gateway.canMakePrivate;
/// The edits in the private studyset [id] a device without the keys wrote
/// readable, by kind (the short reading of 22 Oct, finding 11).
Future<Map<String, int>> unsignedEditsOf(String id) =>
_gateway.unsignedEditsOf(id);
/// Takes those edits in as the person's, sealed.
Future<int> adoptUnsignedEdits(String id) async {
final left = await _gateway.adoptUnsignedEdits(id);
await _refresh();
return left;
}
/// Whether the account has keys this device doesn't hold, so it makes no /// Whether the account has keys this device doesn't hold, so it makes no
/// studyset here (the reading of 22 Oct). /// studyset here (the reading of 22 Oct).
bool get keysMissing => _gateway.keysMissing; bool get keysMissing => _gateway.keysMissing;

View file

@ -215,6 +215,17 @@ class SyncStudysetGateway(
@override @override
bool get canMakePrivate => _privacy.keys.canEncrypt; bool get canMakePrivate => _privacy.keys.canEncrypt;
@override
Future<Map<String, int>> unsignedEditsOf(String id) =>
_privacy.unsignedIn(id);
@override
Future<int> adoptUnsignedEdits(String id) async {
final left = await _privacy.adoptUnsigned(id);
_sync.requestSync();
return left;
}
@override @override
bool get keysMissing => bool get keysMissing =>
!_privacy.keys.canEncrypt && _privacy.keys.accountHasKeys; !_privacy.keys.canEncrypt && _privacy.keys.accountHasKeys;

View file

@ -72,6 +72,18 @@ Future<void> applyStudysetPrivacy(
); );
return; return;
} }
// Finishing: edits a device without the keys wrote meanwhile are taken in
// only once the person agreed, after seeing what they are (the short
// reading of 22 Oct).
if (private && studyset.isPrivate && studyset.isPartlyReadable) {
final unsigned = await studysets.unsignedEditsOf(studyset.id);
final total = unsigned.values.fold(0, (sum, n) => sum + n);
if (total > 0 &&
context.mounted &&
await confirmAdoptingEdits(context, unsigned)) {
await studysets.adoptUnsignedEdits(studyset.id);
}
}
final locked = await studysets.setPrivate(studyset.id, private: private); final locked = await studysets.setPrivate(studyset.id, private: private);
if (locked > 0) { if (locked > 0) {
messenger.showSnackBar( messenger.showSnackBar(
@ -80,6 +92,43 @@ Future<void> applyStudysetPrivacy(
} }
} }
/// Asks whether to take in [unsigned] edits (by kind) a device without the
/// keys wrote while the studyset was made private: taken in, they are
/// sealed as the person's; left, they stay locked.
Future<bool> confirmAdoptingEdits(
BuildContext context,
Map<String, int> unsigned,
) async {
final l10n = AppLocalizations.of(context);
final parts = [
if (unsigned['cards'] case final n? when n > 0) l10n.privacyEditsCards(n),
if (unsigned['notes'] case final n? when n > 0) l10n.privacyEditsNotes(n),
if (unsigned['paths'] case final n? when n > 0) l10n.privacyEditsPaths(n),
if (unsigned['pdfs'] case final n? when n > 0) l10n.privacyEditsPdfs(n),
if (unsigned['studysets'] case final n? when n > 0)
l10n.privacyEditsStudysets(n),
];
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(LucideIcons.shieldAlert),
title: Text(l10n.privacyAdoptTitle),
content: Text(l10n.privacyAdoptBody(parts.join(', '))),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.privacyAdoptLeave),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.privacyAdoptTake),
),
],
),
) ??
false;
}
/// What the person may do from [showStudysetPrivacy]. /// What the person may do from [showStudysetPrivacy].
enum PrivacyChoice() { enum PrivacyChoice() {
makePrivate, makePrivate,

View file

@ -10,6 +10,7 @@ import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_privacy.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -128,4 +129,36 @@ void main() {
expect(seen, 'studyset-1'); expect(seen, 'studyset-1');
expect(scope, (studysetId: 'studyset-1')); expect(scope, (studysetId: 'studyset-1'));
}); });
testWidgets('Finish asks before taking in edits nobody signed, saying how '
'many of each kind', (tester) async {
bool? answer;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: appLocalizationsDelegates,
home: Builder(
builder: (context) => TextButton(
onPressed: () async => answer = await confirmAdoptingEdits(
context,
{'cards': 2, 'notes': 1},
),
child: const Text('finish'),
),
),
),
);
await tester.tap(find.text('finish'));
await tester.pumpAndSettle();
expect(find.textContaining('2 flashcards, 1 note'), findsOneWidget);
await tester.tap(find.text('Leave them locked'));
await tester.pumpAndSettle();
expect(answer, isFalse);
await tester.tap(find.text('finish'));
await tester.pumpAndSettle();
await tester.tap(find.text('Take them in'));
await tester.pumpAndSettle();
expect(answer, isTrue);
});
} }

View file

@ -632,6 +632,60 @@ void main() {
); );
}); });
group('Finish and edits nobody signed', () {
Future<(_Device, String, String)> planted() async {
final laptop = await device(account: alice);
final id = await privateStudyset(laptop, 'Diary');
final card = await laptop.cards.insertCard(
studysetId: id,
position: 'a0',
payload: cardPayload('mine'),
);
// A device without the keys edited the card meanwhile, readable.
await protocol.CardRevision.db.insertRow(
db,
protocol.CardRevision(
id: Privacy.newId(),
cardId: protocol.UuidValue.withValidation(card),
parents: const [],
authorId: const protocol.Uuid().v7obj(),
payload: cardPayload('from the keyless device'),
),
);
return (laptop, id, card);
}
test('counts them by kind; left, they stay locked', () async {
final (laptop, id, card) = await planted();
expect(await laptop.privacy.unsignedIn(id), {'cards': 1});
await laptop.privacy.maintain();
expect(await sealed(card), isFalse);
expect(
[
for (final r in await laptop.cards.revisionsOf({card}))
if (r.payload.lengthInBytes > 0)
utf8.decode(Uint8List.sublistView(r.payload)),
],
[contains('mine')],
);
});
test("taken in, they are sealed as the person's and shown", () async {
final (laptop, id, card) = await planted();
await laptop.privacy.adoptUnsigned(id);
expect(await sealed(card), isTrue);
expect(await laptop.privacy.unsignedIn(id), isEmpty);
expect([
for (final r in await laptop.cards.revisionsOf({card}))
utf8.decode(Uint8List.sublistView(r.payload)),
], containsAll([contains('mine'), contains('keyless')]));
});
});
test("a file that won't move is owed, the tree partly readable, and the " test("a file that won't move is owed, the tree partly readable, and the "
'upkeep goes on', () async { 'upkeep goes on', () async {
final files = _FailingFiles(); final files = _FailingFiles();

View file

@ -77,6 +77,23 @@ class FakeStudysetGateway() implements StudysetGateway {
@override @override
bool keysMissing = false; bool keysMissing = false;
/// The unsigned edits each studyset holds, by kind.
final unsigned = <String, Map<String, int>>{};
/// The studysets whose unsigned edits were taken in.
final adopted = <String>[];
@override
Future<Map<String, int>> unsignedEditsOf(String id) async =>
unsigned[id] ?? const {};
@override
Future<int> adoptUnsignedEdits(String id) async {
adopted.add(id);
unsigned.remove(id);
return 0;
}
@override @override
Future<int> setPrivate(String id, {required bool private}) async { Future<int> setPrivate(String id, {required bool private}) async {
final tree = {id}; final tree = {id};