fix(privacy): Finish shows what it would take in, and takes in exactly that (ADR 0019)

The final reading of 22 Oct found that Finish's question said only how
many edits of each kind there were, so the person couldn't see content the
instance planted, and that taking them in sealed whatever was unsigned when
it ran, answers included without being counted. The dialog now lists each
edit with its kind and what it says (answers too), and taking them in seals
exactly the rows listed, each only while it still says what was shown; one
that came or changed after the list stays locked.

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 11:34:42 +02:00
commit c9c6533afc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 434 additions and 243 deletions

View file

@ -2078,20 +2078,17 @@
"@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?", "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", "privacyAdoptTake": "Übernehmen",
"privacyAdoptLeave": "Gesperrt lassen", "privacyAdoptLeave": "Gesperrt lassen",
"privacyEditsCards": "{count, plural, =1{1 Karteikarte} other{{count} Karteikarten}}", "privacyAdoptListBody": "Während es privat gemacht wurde, wurde das hier ohne deine Schlüssel geschrieben: von einem Gerät, das sie nicht hatte, oder von der Instanz. Übernommen, werden genau diese als deine versiegelt; gelassen, bleiben sie gesperrt.",
"@privacyEditsCards": {"placeholders": {"count": {"type": "int"}}}, "privacyEditCard": "Karteikarte",
"privacyEditsNotes": "{count, plural, =1{1 Notiz} other{{count} Notizen}}", "privacyEditNote": "Notiz",
"@privacyEditsNotes": {"placeholders": {"count": {"type": "int"}}}, "privacyEditPath": "Lernpfad",
"privacyEditsPaths": "{count, plural, =1{1 Änderung an Lernpfaden} other{{count} Änderungen an Lernpfaden}}", "privacyEditStep": "Schritt",
"@privacyEditsPaths": {"placeholders": {"count": {"type": "int"}}}, "privacyEditPdf": "PDF",
"privacyEditsPdfs": "{count, plural, =1{1 PDF} other{{count} PDFs}}", "privacyEditStudyset": "Lernset",
"@privacyEditsPdfs": {"placeholders": {"count": {"type": "int"}}}, "privacyEditAnswer": "Antwort",
"privacyEditsStudysets": "{count, plural, =1{1 Lernset} other{{count} Lernsets}}", "privacyEditNoText": "(kein Text)",
"@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

@ -2444,20 +2444,17 @@
"@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?", "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", "privacyAdoptTake": "Take them in",
"privacyAdoptLeave": "Leave them locked", "privacyAdoptLeave": "Leave them locked",
"privacyEditsCards": "{count, plural, =1{1 flashcard} other{{count} flashcards}}", "privacyAdoptListBody": "While it was made private, these were written without your keys: by a device that didn't have them, or by the instance. Taken in, exactly these are sealed as yours; left, they stay locked.",
"@privacyEditsCards": {"placeholders": {"count": {"type": "int"}}}, "privacyEditCard": "Flashcard",
"privacyEditsNotes": "{count, plural, =1{1 note} other{{count} notes}}", "privacyEditNote": "Note",
"@privacyEditsNotes": {"placeholders": {"count": {"type": "int"}}}, "privacyEditPath": "Learning path",
"privacyEditsPaths": "{count, plural, =1{1 change to learning paths} other{{count} changes to learning paths}}", "privacyEditStep": "Step",
"@privacyEditsPaths": {"placeholders": {"count": {"type": "int"}}}, "privacyEditPdf": "PDF",
"privacyEditsPdfs": "{count, plural, =1{1 PDF} other{{count} PDFs}}", "privacyEditStudyset": "Studyset",
"@privacyEditsPdfs": {"placeholders": {"count": {"type": "int"}}}, "privacyEditAnswer": "Answer",
"privacyEditsStudysets": "{count, plural, =1{1 studyset} other{{count} studysets}}", "privacyEditNoText": "(no text)",
"@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

@ -12249,12 +12249,6 @@ abstract class AppLocalizations {
/// **'Take in edits nobody signed?'** /// **'Take in edits nobody signed?'**
String get privacyAdoptTitle; 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. /// No description provided for @privacyAdoptTake.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -12267,35 +12261,59 @@ abstract class AppLocalizations {
/// **'Leave them locked'** /// **'Leave them locked'**
String get privacyAdoptLeave; String get privacyAdoptLeave;
/// No description provided for @privacyEditsCards. /// No description provided for @privacyAdoptListBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count, plural, =1{1 flashcard} other{{count} flashcards}}'** /// **'While it was made private, these were written without your keys: by a device that didn\'t have them, or by the instance. Taken in, exactly these are sealed as yours; left, they stay locked.'**
String privacyEditsCards(int count); String get privacyAdoptListBody;
/// No description provided for @privacyEditsNotes. /// No description provided for @privacyEditCard.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count, plural, =1{1 note} other{{count} notes}}'** /// **'Flashcard'**
String privacyEditsNotes(int count); String get privacyEditCard;
/// No description provided for @privacyEditsPaths. /// No description provided for @privacyEditNote.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count, plural, =1{1 change to learning paths} other{{count} changes to learning paths}}'** /// **'Note'**
String privacyEditsPaths(int count); String get privacyEditNote;
/// No description provided for @privacyEditsPdfs. /// No description provided for @privacyEditPath.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count, plural, =1{1 PDF} other{{count} PDFs}}'** /// **'Learning path'**
String privacyEditsPdfs(int count); String get privacyEditPath;
/// No description provided for @privacyEditsStudysets. /// No description provided for @privacyEditStep.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count, plural, =1{1 studyset} other{{count} studysets}}'** /// **'Step'**
String privacyEditsStudysets(int count); String get privacyEditStep;
/// No description provided for @privacyEditPdf.
///
/// In en, this message translates to:
/// **'PDF'**
String get privacyEditPdf;
/// No description provided for @privacyEditStudyset.
///
/// In en, this message translates to:
/// **'Studyset'**
String get privacyEditStudyset;
/// No description provided for @privacyEditAnswer.
///
/// In en, this message translates to:
/// **'Answer'**
String get privacyEditAnswer;
/// No description provided for @privacyEditNoText.
///
/// In en, this message translates to:
/// **'(no text)'**
String get privacyEditNoText;
/// No description provided for @studysetLockedTitle. /// No description provided for @studysetLockedTitle.
/// ///

View file

@ -7766,11 +7766,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get privacyAdoptTitle => 'Unsignierte Änderungen übernehmen?'; 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 @override
String get privacyAdoptTake => 'Übernehmen'; String get privacyAdoptTake => 'Übernehmen';
@ -7778,59 +7773,32 @@ class AppLocalizationsDe extends AppLocalizations {
String get privacyAdoptLeave => 'Gesperrt lassen'; String get privacyAdoptLeave => 'Gesperrt lassen';
@override @override
String privacyEditsCards(int count) { String get privacyAdoptListBody =>
String _temp0 = intl.Intl.pluralLogic( 'Während es privat gemacht wurde, wurde das hier ohne deine Schlüssel geschrieben: von einem Gerät, das sie nicht hatte, oder von der Instanz. Übernommen, werden genau diese als deine versiegelt; gelassen, bleiben sie gesperrt.';
count,
locale: localeName,
other: '$count Karteikarten',
one: '1 Karteikarte',
);
return '$_temp0';
}
@override @override
String privacyEditsNotes(int count) { String get privacyEditCard => 'Karteikarte';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Notizen',
one: '1 Notiz',
);
return '$_temp0';
}
@override @override
String privacyEditsPaths(int count) { String get privacyEditNote => 'Notiz';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Änderungen an Lernpfaden',
one: '1 Änderung an Lernpfaden',
);
return '$_temp0';
}
@override @override
String privacyEditsPdfs(int count) { String get privacyEditPath => 'Lernpfad';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count PDFs',
one: '1 PDF',
);
return '$_temp0';
}
@override @override
String privacyEditsStudysets(int count) { String get privacyEditStep => 'Schritt';
String _temp0 = intl.Intl.pluralLogic(
count, @override
locale: localeName, String get privacyEditPdf => 'PDF';
other: '$count Lernsets',
one: '1 Lernset', @override
); String get privacyEditStudyset => 'Lernset';
return '$_temp0';
} @override
String get privacyEditAnswer => 'Antwort';
@override
String get privacyEditNoText => '(kein Text)';
@override @override
String get studysetLockedTitle => 'Gesperrt'; String get studysetLockedTitle => 'Gesperrt';

View file

@ -7693,11 +7693,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get privacyAdoptTitle => 'Take in edits nobody signed?'; 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 @override
String get privacyAdoptTake => 'Take them in'; String get privacyAdoptTake => 'Take them in';
@ -7705,59 +7700,32 @@ class AppLocalizationsEn extends AppLocalizations {
String get privacyAdoptLeave => 'Leave them locked'; String get privacyAdoptLeave => 'Leave them locked';
@override @override
String privacyEditsCards(int count) { String get privacyAdoptListBody =>
String _temp0 = intl.Intl.pluralLogic( 'While it was made private, these were written without your keys: by a device that didn\'t have them, or by the instance. Taken in, exactly these are sealed as yours; left, they stay locked.';
count,
locale: localeName,
other: '$count flashcards',
one: '1 flashcard',
);
return '$_temp0';
}
@override @override
String privacyEditsNotes(int count) { String get privacyEditCard => 'Flashcard';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count notes',
one: '1 note',
);
return '$_temp0';
}
@override @override
String privacyEditsPaths(int count) { String get privacyEditNote => 'Note';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count changes to learning paths',
one: '1 change to learning paths',
);
return '$_temp0';
}
@override @override
String privacyEditsPdfs(int count) { String get privacyEditPath => 'Learning path';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count PDFs',
one: '1 PDF',
);
return '$_temp0';
}
@override @override
String privacyEditsStudysets(int count) { String get privacyEditStep => 'Step';
String _temp0 = intl.Intl.pluralLogic(
count, @override
locale: localeName, String get privacyEditPdf => 'PDF';
other: '$count studysets',
one: '1 studyset', @override
); String get privacyEditStudyset => 'Studyset';
return '$_temp0';
} @override
String get privacyEditAnswer => 'Answer';
@override
String get privacyEditNoText => '(no text)';
@override @override
String get studysetLockedTitle => 'Locked'; String get studysetLockedTitle => 'Locked';

View file

@ -13,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
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:serverpod_offline_sync_client/offline_sync.dart'; import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// A write this device refuses, as it would leave what is private readable /// A write this device refuses, as it would leave what is private readable
@ -684,25 +685,41 @@ class Privacy(
/// The edits in [studysetId]'s private scope written readable without the /// The edits in [studysetId]'s private scope written readable without the
/// account's signature (a device without the keys, while it was made /// account's signature (a device without the keys, while it was made
/// private), by kind (`cards`, `notes`, `paths`, `pdfs`, `studysets`): /// private; or the instance), each with what it says: locked until the
/// locked until the person takes them in ([adoptUnsigned]). /// person, having seen them, takes them in ([adoptUnsigned], the final
Future<Map<String, int>> unsignedIn(String studysetId) async { /// reading of 22 Oct).
Future<List<UnsignedEdit>> unsignedIn(String studysetId) async {
final ids = {for (final id in await scopeOf(studysetId)) _uuid(id)}; final ids = {for (final id in await scopeOf(studysetId)) _uuid(id)};
final counts = <String, int>{}; final edits = <UnsignedEdit>[];
void count( void add(
String kind, UnsignedEditKind kind,
String table, String table,
String id, String id,
String bound, String bound,
Envelope e, Envelope e, {
) { String column = 'payload',
}) {
if (e.keyId != null || e.payload.lengthInBytes == 0) { if (e.keyId != null || e.payload.lengthInBytes == 0) {
return; return;
} }
if (open(e, table: table, id: id, bound: bound) case Readable( if (open(e, table: table, id: id, bound: bound, column: column)
trusted: false, case Readable(trusted: false, :final payload)) {
)) { edits.add(
counts[kind] = (counts[kind] ?? 0) + 1; UnsignedEdit(
kind: kind,
table: table,
id: id,
preview: kind == UnsignedEditKind.answer
? _shortened(
utf8.decode(
Uint8List.sublistView(payload),
allowMalformed: true,
),
)
: previewOf(payload),
payload: payload,
),
);
} }
} }
@ -710,7 +727,7 @@ class Privacy(
_db, _db,
where: (t) => t.id.inSet(ids) & t.keyId.equals(null), where: (t) => t.id.inSet(ids) & t.keyId.equals(null),
)) { )) {
count('studysets', 'studyset', r.id!.uuid, '', ( add(UnsignedEditKind.studyset, 'studyset', r.id!.uuid, '', (
keyId: r.keyId, keyId: r.keyId,
nonce: r.nonce, nonce: r.nonce,
payload: r.payload, payload: r.payload,
@ -727,11 +744,13 @@ class Privacy(
_db, _db,
where: (t) => t.cardId.inSet(cards) & t.keyId.equals(null), where: (t) => t.cardId.inSet(cards) & t.keyId.equals(null),
)) { )) {
count('cards', 'card_revision', r.id!.uuid, 'card:${r.cardId.uuid}', ( add(
keyId: r.keyId, UnsignedEditKind.card,
nonce: r.nonce, 'card_revision',
payload: r.payload, r.id!.uuid,
)); 'card:${r.cardId.uuid}',
(keyId: r.keyId, nonce: r.nonce, payload: r.payload),
);
} }
final notes = { final notes = {
for (final note in await protocol.Note.db.find( for (final note in await protocol.Note.db.find(
@ -744,27 +763,25 @@ class Privacy(
_db, _db,
where: (t) => t.noteId.inSet(notes) & t.keyId.equals(null), where: (t) => t.noteId.inSet(notes) & t.keyId.equals(null),
)) { )) {
count('notes', 'note_revision', r.id!.uuid, 'note:${r.noteId.uuid}', ( add(
keyId: r.keyId, UnsignedEditKind.note,
nonce: r.nonce, 'note_revision',
payload: r.payload, r.id!.uuid,
)); 'note:${r.noteId.uuid}',
(keyId: r.keyId, nonce: r.nonce, payload: r.payload),
);
} }
final paths = await protocol.LearningPath.db.find( final paths = await protocol.LearningPath.db.find(
_db, _db,
where: (t) => t.studysetId.inSet(ids), where: (t) => t.studysetId.inSet(ids),
); );
for (final r in paths) { for (final r in paths) {
count( add(
'paths', UnsignedEditKind.path,
'learning_path', 'learning_path',
r.id!.uuid, r.id!.uuid,
'studyset:${r.studysetId.uuid}', 'studyset:${r.studysetId.uuid}',
( (keyId: r.keyId, nonce: r.nonce, payload: r.payload),
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
),
); );
} }
final steps = { final steps = {
@ -778,41 +795,105 @@ class Privacy(
_db, _db,
where: (t) => t.stepId.inSet(steps) & t.keyId.equals(null), where: (t) => t.stepId.inSet(steps) & t.keyId.equals(null),
)) { )) {
count( add(
'paths', UnsignedEditKind.step,
'learning_path_step_revision', 'learning_path_step_revision',
r.id!.uuid, r.id!.uuid,
'step:${r.stepId.uuid}', 'step:${r.stepId.uuid}',
( (keyId: r.keyId, nonce: r.nonce, payload: r.payload),
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
),
); );
} }
for (final r in await protocol.LearningPathAnswer.db.find(
_db,
where: (t) => t.stepId.inSet(steps) & t.keyId.equals(null),
)) {
if (r.answer case final text?) {
if (answerEnvelope(r.keyId, r.nonce, text) case final e?) {
add(
UnsignedEditKind.answer,
'learning_path_answer',
r.id!.uuid,
'step:${r.stepId.uuid}',
e,
column: 'answer',
);
}
}
}
for (final r in await protocol.PdfSource.db.find( for (final r in await protocol.PdfSource.db.find(
_db, _db,
where: (t) => t.studysetId.inSet(ids) & t.keyId.equals(null), where: (t) => t.studysetId.inSet(ids) & t.keyId.equals(null),
)) { )) {
count('pdfs', 'pdf_source', r.id!.uuid, 'studyset:${r.studysetId.uuid}', ( add(
keyId: r.keyId, UnsignedEditKind.pdf,
nonce: r.nonce, 'pdf_source',
payload: r.payload, r.id!.uuid,
)); 'studyset:${r.studysetId.uuid}',
(keyId: r.keyId, nonce: r.nonce, payload: r.payload),
);
} }
return counts; return edits;
} }
/// Takes in the edits [unsignedIn] counts, once the person confirmed: they /// What a payload says, for the person to see before taking it in: the
/// are sealed with the tree's key as theirs. Returns how many rows or /// text of its titles, sides and documents, shortened; empty for none.
static String previewOf(ByteData payload) {
final parts = <String>[];
void visit(Object? value, [String? key]) {
switch (value) {
case final String text
when const {
'insert',
'title',
'front',
'back',
'text',
'name',
'reading',
'fileName',
}.contains(key):
if (text.trim().isNotEmpty) {
parts.add(text.trim());
}
case final Map<String, Object?> map:
map.forEach((key, value) => visit(value, key));
case final List<Object?> list:
for (final item in list) {
visit(item, key);
}
}
}
visit(_json(payload));
return _shortened(parts.join(' · '));
}
static String _shortened(String text) {
final flat = text.replaceAll(RegExp(r'\s+'), ' ').trim();
return flat.length <= 120 ? flat : '${flat.substring(0, 119)}…';
}
/// Takes in exactly the edits [listed] ([unsignedIn]), once the person
/// saw and confirmed them: each is sealed with the tree's key as theirs,
/// and only while it still says what was listed; nothing else readable
/// is taken in (the final reading of 22 Oct). Returns how many rows or
/// files are still left. /// files are still left.
Future<int> adoptUnsigned(String studysetId) async { Future<int> adoptUnsigned(
String studysetId,
List<UnsignedEdit> listed,
) async {
final keyId = _privateKeyOf(studysetId); final keyId = _privateKeyOf(studysetId);
if (keyId == null || !keys.canEncrypt) { if (keyId == null || !keys.canEncrypt) {
throw const PrivacyRefused(PrivacyRefusal.keysMissing); throw const PrivacyRefused(PrivacyRefusal.keysMissing);
} }
_unmovable.clear(); _unmovable.clear();
return await rekeyTree(studysetId, keyId, adopt: true); return await rekeyTree(
studysetId,
keyId,
adoptOnly: {
for (final edit in listed) '${edit.table}|${edit.id}': edit.payload,
},
);
} }
/// 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
@ -981,6 +1062,7 @@ class Privacy(
String? keyId, { String? keyId, {
bool adopt = false, bool adopt = false,
bool foreign = false, bool foreign = false,
Map<String, ByteData> adoptOnly = const {},
}) => _rewrites.run(() async { }) => _rewrites.run(() async {
final done = Completer<void>(); final done = Completer<void>();
_rewriting = done.future; _rewriting = done.future;
@ -994,6 +1076,7 @@ class Privacy(
cardIds: null, cardIds: null,
keyId: keyId, keyId: keyId,
adopt: adopt, adopt: adopt,
adoptOnly: adoptOnly,
foreign: foreign, foreign: foreign,
top: studysetId, top: studysetId,
); );
@ -1047,6 +1130,7 @@ class Privacy(
cardIds: cardIds, cardIds: cardIds,
keyId: keyId, keyId: keyId,
adopt: adopt, adopt: adopt,
adoptOnly: const {},
foreign: true, foreign: true,
top: null, top: null,
); );
@ -1063,6 +1147,7 @@ class Privacy(
required Set<String>? cardIds, required Set<String>? cardIds,
required String? keyId, required String? keyId,
required bool adopt, required bool adopt,
required Map<String, ByteData> adoptOnly,
required bool foreign, required bool foreign,
required String? top, required String? top,
}) async { }) async {
@ -1073,6 +1158,7 @@ class Privacy(
cardIds: cardIds, cardIds: cardIds,
keyId: keyId, keyId: keyId,
adopt: adopt, adopt: adopt,
adoptOnly: adoptOnly,
foreign: foreign, foreign: foreign,
top: top, top: top,
written: written, written: written,
@ -1100,6 +1186,7 @@ class Privacy(
required Set<String>? cardIds, required Set<String>? cardIds,
required String? keyId, required String? keyId,
required bool adopt, required bool adopt,
required Map<String, ByteData> adoptOnly,
required bool foreign, required bool foreign,
required String? top, required String? top,
required List<bool> written, required List<bool> written,
@ -1190,6 +1277,13 @@ class Privacy(
); );
} }
/// Whether the person took in the row [row] as it says [payload]: listed
/// so, and unchanged since.
bool listed(String row, ByteData payload) => switch (adoptOnly[row]) {
final shown? => _same(shown, payload),
null => false,
};
// Every row with an envelope, opened once; the top studyset's last. // Every row with an envelope, opened once; the top studyset's last.
final rows = <_Row>[ final rows = <_Row>[
for (final r in cardRevisions) for (final r in cardRevisions)
@ -1276,7 +1370,8 @@ class Privacy(
trusted || trusted ||
adopt || adopt ||
payload.lengthInBytes == 0 || payload.lengthInBytes == 0 ||
r.current.keyId != null, r.current.keyId != null ||
listed('${r.table}|${r.id}', payload),
}; };
// The files under another key, each added anew under this one. // The files under another key, each added anew under this one.
@ -1461,7 +1556,13 @@ class Privacy(
switch (opened) { switch (opened) {
case Readable(:final payload, :final trusted) case Readable(:final payload, :final trusted)
when _ownKey(current.keyId, keyId, top, foreign) && when _ownKey(current.keyId, keyId, top, foreign) &&
(trusted || adopt || current.keyId != null): (trusted ||
adopt ||
current.keyId != null ||
listed(
'learning_path_answer|${answer.id!.uuid}',
payload,
)):
if (current.keyId == keyId && (keyId != null || trusted)) { if (current.keyId == keyId && (keyId != null || trusted)) {
_keepItem(item, keyId); _keepItem(item, keyId);
continue; continue;

View file

@ -0,0 +1,44 @@
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
/// What an edit nobody signed is (ADR 0019, the final reading of 22 Oct).
enum UnsignedEditKind() {
card,
note,
path,
step,
pdf,
studyset,
answer,
}
/// An edit in a private studyset written readable without the account's
/// signature (a device without the keys, while the studyset was made
/// private; or the instance): locked until the person, having seen it,
/// takes it in. Taken in, exactly this row is sealed, and only while it
/// still says [payload].
final class const UnsignedEdit({
required final UnsignedEditKind kind,
/// The table the row is in.
required final String table,
/// The row's id.
required final String id,
/// What it says, shortened, for the person to see; empty for none.
required final String preview,
/// Its payload as listed.
required final ByteData payload,
}) with Equatable {
@override
List<Object?> get props => [
kind,
table,
id,
preview,
Uint8List.sublistView(payload),
];
}

View file

@ -1,5 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
/// A studyset's row as the device's database stores it: the payload, and the /// A studyset's row as the device's database stores it: the payload, and the
/// columns that change on their own (ADR 0004). Whether it is private (ADR /// columns that change on their own (ADR 0004). Whether it is private (ADR
/// 0019): its payload is opened with its tree's key, and `locked` where /// 0019): its payload is opened with its tree's key, and `locked` where
@ -52,12 +54,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 /// The edits in the private studyset [id] written readable without the
/// readable, by kind; locked until taken in. /// account's signature, each with what it says; locked until taken in.
Future<Map<String, int>> unsignedEditsOf(String id); Future<List<UnsignedEdit>> unsignedEditsOf(String id);
/// Seals the edits [unsignedEditsOf] counts as the person's. /// Seals exactly the edits [listed] ([unsignedEditsOf]) as the person's,
Future<int> adoptUnsignedEdits(String id); /// each only while it says what was listed.
Future<int> adoptUnsignedEdits(String id, List<UnsignedEdit> listed);
/// 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

View file

@ -6,6 +6,7 @@ import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_core/halkyon_core.dart' show SerialQueue; import 'package:halkyon_core/halkyon_core.dart' show SerialQueue;
import 'package:halkyon_learn_flutter/src/core/async/latest_value.dart'; import 'package:halkyon_learn_flutter/src/core/async/latest_value.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart'; import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart'; import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
@ -131,14 +132,15 @@ 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 /// The edits in the private studyset [id] written readable without the
/// readable, by kind (the short reading of 22 Oct, finding 11). /// account's signature, each with what it says (the final reading of 22
Future<Map<String, int>> unsignedEditsOf(String id) => /// Oct).
Future<List<UnsignedEdit>> unsignedEditsOf(String id) =>
_gateway.unsignedEditsOf(id); _gateway.unsignedEditsOf(id);
/// Takes those edits in as the person's, sealed. /// Takes exactly the edits [listed] in as the person's, sealed.
Future<int> adoptUnsignedEdits(String id) async { Future<int> adoptUnsignedEdits(String id, List<UnsignedEdit> listed) async {
final left = await _gateway.adoptUnsignedEdits(id); final left = await _gateway.adoptUnsignedEdits(id, listed);
await _refresh(); await _refresh();
return left; return left;
} }

View file

@ -4,6 +4,7 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol; import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart'; import 'package:serverpod_offline_sync_client/offline_sync.dart';
@ -219,12 +220,12 @@ class SyncStudysetGateway(
bool get canMakePrivate => _privacy.keys.canEncrypt; bool get canMakePrivate => _privacy.keys.canEncrypt;
@override @override
Future<Map<String, int>> unsignedEditsOf(String id) => Future<List<UnsignedEdit>> unsignedEditsOf(String id) =>
_privacy.unsignedIn(id); _privacy.unsignedIn(id);
@override @override
Future<int> adoptUnsignedEdits(String id) async { Future<int> adoptUnsignedEdits(String id, List<UnsignedEdit> listed) async {
final left = await _privacy.adoptUnsigned(id); final left = await _privacy.adoptUnsigned(id, listed);
_sync.requestSync(); _sync.requestSync();
return left; return left;
} }

View file

@ -1,5 +1,6 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.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';
@ -77,11 +78,12 @@ Future<void> applyStudysetPrivacy(
// reading of 22 Oct). // reading of 22 Oct).
if (private && studyset.isPrivate && studyset.isPartlyReadable) { if (private && studyset.isPrivate && studyset.isPartlyReadable) {
final unsigned = await studysets.unsignedEditsOf(studyset.id); final unsigned = await studysets.unsignedEditsOf(studyset.id);
final total = unsigned.values.fold(0, (sum, n) => sum + n); if (unsigned.isNotEmpty &&
if (total > 0 &&
context.mounted && context.mounted &&
await confirmAdoptingEdits(context, unsigned)) { await confirmAdoptingEdits(context, unsigned)) {
await studysets.adoptUnsignedEdits(studyset.id); // Exactly those shown, each as it was shown (the final reading of 22
// Oct).
await studysets.adoptUnsignedEdits(studyset.id, unsigned);
} }
} }
final locked = await studysets.setPrivate(studyset.id, private: private); final locked = await studysets.setPrivate(studyset.id, private: private);
@ -92,28 +94,49 @@ Future<void> applyStudysetPrivacy(
} }
} }
/// Asks whether to take in [unsigned] edits (by kind) a device without the /// Asks whether to take in the [unsigned] edits written readable without
/// keys wrote while the studyset was made private: taken in, they are /// the account's signature while the studyset was made private, each shown
/// sealed as the person's; left, they stay locked. /// with what it says (the final reading of 22 Oct): taken in, exactly those
/// are sealed as the person's; left, they stay locked.
Future<bool> confirmAdoptingEdits( Future<bool> confirmAdoptingEdits(
BuildContext context, BuildContext context,
Map<String, int> unsigned, List<UnsignedEdit> unsigned,
) async { ) async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final parts = [ String kindOf(UnsignedEditKind kind) => switch (kind) {
if (unsigned['cards'] case final n? when n > 0) l10n.privacyEditsCards(n), UnsignedEditKind.card => l10n.privacyEditCard,
if (unsigned['notes'] case final n? when n > 0) l10n.privacyEditsNotes(n), UnsignedEditKind.note => l10n.privacyEditNote,
if (unsigned['paths'] case final n? when n > 0) l10n.privacyEditsPaths(n), UnsignedEditKind.path => l10n.privacyEditPath,
if (unsigned['pdfs'] case final n? when n > 0) l10n.privacyEditsPdfs(n), UnsignedEditKind.step => l10n.privacyEditStep,
if (unsigned['studysets'] case final n? when n > 0) UnsignedEditKind.pdf => l10n.privacyEditPdf,
l10n.privacyEditsStudysets(n), UnsignedEditKind.studyset => l10n.privacyEditStudyset,
]; UnsignedEditKind.answer => l10n.privacyEditAnswer,
};
return await showDialog<bool>( return await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
icon: const Icon(LucideIcons.shieldAlert), icon: const Icon(LucideIcons.shieldAlert),
title: Text(l10n.privacyAdoptTitle), title: Text(l10n.privacyAdoptTitle),
content: Text(l10n.privacyAdoptBody(parts.join(', '))), content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.privacyAdoptListBody),
const SizedBox(height: 12),
for (final edit in unsigned)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'${kindOf(edit.kind)}: '
'${edit.preview.isEmpty ? l10n.privacyEditNoText : edit.preview}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: () => Navigator.of(context).pop(false),

View file

@ -1,9 +1,12 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations_en.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations_en.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart'; import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart'; import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.dart'; import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
@ -130,9 +133,16 @@ void main() {
expect(scope, (studysetId: 'studyset-1')); expect(scope, (studysetId: 'studyset-1'));
}); });
testWidgets('Finish asks before taking in edits nobody signed, saying how ' testWidgets('Finish asks before taking in edits nobody signed, showing '
'many of each kind', (tester) async { 'each with what it says', (tester) async {
bool? answer; bool? answer;
UnsignedEdit edit(UnsignedEditKind kind, String preview) => UnsignedEdit(
kind: kind,
table: 'card_revision',
id: preview,
preview: preview,
payload: ByteData(0),
);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
localizationsDelegates: appLocalizationsDelegates, localizationsDelegates: appLocalizationsDelegates,
@ -140,7 +150,11 @@ void main() {
builder: (context) => TextButton( builder: (context) => TextButton(
onPressed: () async => answer = await confirmAdoptingEdits( onPressed: () async => answer = await confirmAdoptingEdits(
context, context,
{'cards': 2, 'notes': 1}, [
edit(UnsignedEditKind.card, '犬 · dog'),
edit(UnsignedEditKind.note, 'Week 3'),
edit(UnsignedEditKind.answer, ''),
],
), ),
child: const Text('finish'), child: const Text('finish'),
), ),
@ -150,7 +164,11 @@ void main() {
await tester.tap(find.text('finish')); await tester.tap(find.text('finish'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('2 flashcards, 1 note'), findsOneWidget); expect(find.textContaining('犬 · dog'), findsOneWidget);
expect(find.textContaining('Week 3'), findsOneWidget);
expect(find.textContaining('Flashcard'), findsOneWidget);
expect(find.textContaining('Note'), findsOneWidget);
expect(find.textContaining('Answer'), findsOneWidget);
await tester.tap(find.text('Leave them locked')); await tester.tap(find.text('Leave them locked'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(answer, isFalse); expect(answer, isFalse);

View file

@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/sync_content_key_gateway.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/data/sync_content_key_gateway.dart';
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/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/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';
@ -659,10 +660,13 @@ void main() {
return (laptop, id, card); return (laptop, id, card);
} }
test('counts them by kind; left, they stay locked', () async { test('lists each with what it says; left, they stay locked', () async {
final (laptop, id, card) = await planted(); final (laptop, id, card) = await planted();
expect(await laptop.privacy.unsignedIn(id), {'cards': 1}); final edits = await laptop.privacy.unsignedIn(id);
expect(edits, hasLength(1));
expect(edits.single.kind, UnsignedEditKind.card);
expect(edits.single.preview, contains('from the keyless device'));
await laptop.privacy.maintain(); await laptop.privacy.maintain();
expect(await sealed(card), isFalse); expect(await sealed(card), isFalse);
@ -676,17 +680,63 @@ void main() {
); );
}); });
test("taken in, they are sealed as the person's and shown", () async { test("taken in, exactly those listed are sealed as the person's and "
'shown; one that came after the list stays locked', () async {
final (laptop, id, card) = await planted(); final (laptop, id, card) = await planted();
final listed = await laptop.privacy.unsignedIn(id);
// A sync brings another one nobody signed after the list was shown.
final later = Privacy.newId();
await instanceWrites(
protocol.CardRevision(
id: later,
cardId: protocol.UuidValue.withValidation(card),
parents: const [],
authorId: const protocol.Uuid().v7obj(),
payload: cardPayload('planted after the list'),
),
);
await laptop.privacy.adoptUnsigned(id); await laptop.privacy.adoptUnsigned(id, listed);
expect(await sealed(card), isTrue); expect(
expect(await laptop.privacy.unsignedIn(id), isEmpty); (await protocol.CardRevision.db.findById(
expect([ db,
for (final r in await laptop.cards.revisionsOf({card})) protocol.UuidValue.withValidation(listed.single.id),
utf8.decode(Uint8List.sublistView(r.payload)), ))!.keyId,
], containsAll([contains('mine'), contains('keyless')])); isNotNull,
);
expect(
(await protocol.CardRevision.db.findById(db, later))!.keyId,
isNull,
);
expect(
[
for (final r in await laptop.cards.revisionsOf({card}))
if (r.payload.lengthInBytes > 0)
utf8.decode(Uint8List.sublistView(r.payload)),
],
containsAll([contains('mine'), contains('keyless')]),
);
});
test('an edit changed since it was listed is not taken in', () async {
final (laptop, id, card) = await planted();
final listed = await laptop.privacy.unsignedIn(id);
final row = (await protocol.CardRevision.db.findById(
db,
protocol.UuidValue.withValidation(listed.single.id),
))!;
await protocol.CardRevision.db.updateRow(
db,
row.copyWith(payload: cardPayload('swapped after the list')),
);
await laptop.privacy.adoptUnsigned(id, listed);
expect(
(await protocol.CardRevision.db.findById(db, row.id!))!.keyId,
isNull,
);
}); });
}); });

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
/// A [StudysetGateway] in memory: ids are `studyset-1`, `studyset-2`, …, and /// A [StudysetGateway] in memory: ids are `studyset-1`, `studyset-2`, …, and
@ -77,18 +78,18 @@ class FakeStudysetGateway() implements StudysetGateway {
@override @override
bool keysMissing = false; bool keysMissing = false;
/// The unsigned edits each studyset holds, by kind. /// The unsigned edits each studyset holds.
final unsigned = <String, Map<String, int>>{}; final unsigned = <String, List<UnsignedEdit>>{};
/// The studysets whose unsigned edits were taken in. /// The studysets whose unsigned edits were taken in.
final adopted = <String>[]; final adopted = <String>[];
@override @override
Future<Map<String, int>> unsignedEditsOf(String id) async => Future<List<UnsignedEdit>> unsignedEditsOf(String id) async =>
unsigned[id] ?? const {}; unsigned[id] ?? const [];
@override @override
Future<int> adoptUnsignedEdits(String id) async { Future<int> adoptUnsignedEdits(String id, List<UnsignedEdit> listed) async {
adopted.add(id); adopted.add(id);
unsigned.remove(id); unsigned.remove(id);
return 0; return 0;