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"}}},
"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"}}},
"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.",
"privacyEditCard": "Karteikarte",
"privacyEditNote": "Notiz",
"privacyEditPath": "Lernpfad",
"privacyEditStep": "Schritt",
"privacyEditPdf": "PDF",
"privacyEditStudyset": "Lernset",
"privacyEditAnswer": "Antwort",
"privacyEditNoText": "(kein Text)",
"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.",
"studysetVisibilityPartly": "Privat, teils lesbar",

View file

@ -2444,20 +2444,17 @@
"@publishTagsSent": {"placeholders": {"names": {"type": "String"}}},
"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"}}},
"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.",
"privacyEditCard": "Flashcard",
"privacyEditNote": "Note",
"privacyEditPath": "Learning path",
"privacyEditStep": "Step",
"privacyEditPdf": "PDF",
"privacyEditStudyset": "Studyset",
"privacyEditAnswer": "Answer",
"privacyEditNoText": "(no text)",
"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.",
"studysetVisibilityPartly": "Private, partly readable",

View file

@ -12249,12 +12249,6 @@ abstract class AppLocalizations {
/// **'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:
@ -12267,35 +12261,59 @@ abstract class AppLocalizations {
/// **'Leave them locked'**
String get privacyAdoptLeave;
/// No description provided for @privacyEditsCards.
/// No description provided for @privacyAdoptListBody.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 flashcard} other{{count} flashcards}}'**
String privacyEditsCards(int count);
/// **'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 get privacyAdoptListBody;
/// No description provided for @privacyEditsNotes.
/// No description provided for @privacyEditCard.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 note} other{{count} notes}}'**
String privacyEditsNotes(int count);
/// **'Flashcard'**
String get privacyEditCard;
/// No description provided for @privacyEditsPaths.
/// No description provided for @privacyEditNote.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 change to learning paths} other{{count} changes to learning paths}}'**
String privacyEditsPaths(int count);
/// **'Note'**
String get privacyEditNote;
/// No description provided for @privacyEditsPdfs.
/// No description provided for @privacyEditPath.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 PDF} other{{count} PDFs}}'**
String privacyEditsPdfs(int count);
/// **'Learning path'**
String get privacyEditPath;
/// No description provided for @privacyEditsStudysets.
/// No description provided for @privacyEditStep.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 studyset} other{{count} studysets}}'**
String privacyEditsStudysets(int count);
/// **'Step'**
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.
///

View file

@ -7766,11 +7766,6 @@ class AppLocalizationsDe extends AppLocalizations {
@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';
@ -7778,59 +7773,32 @@ class AppLocalizationsDe extends AppLocalizations {
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';
}
String get 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.';
@override
String privacyEditsNotes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Notizen',
one: '1 Notiz',
);
return '$_temp0';
}
String get privacyEditCard => 'Karteikarte';
@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';
}
String get privacyEditNote => 'Notiz';
@override
String privacyEditsPdfs(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count PDFs',
one: '1 PDF',
);
return '$_temp0';
}
String get privacyEditPath => 'Lernpfad';
@override
String privacyEditsStudysets(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Lernsets',
one: '1 Lernset',
);
return '$_temp0';
}
String get privacyEditStep => 'Schritt';
@override
String get privacyEditPdf => 'PDF';
@override
String get privacyEditStudyset => 'Lernset';
@override
String get privacyEditAnswer => 'Antwort';
@override
String get privacyEditNoText => '(kein Text)';
@override
String get studysetLockedTitle => 'Gesperrt';

View file

@ -7693,11 +7693,6 @@ class AppLocalizationsEn extends AppLocalizations {
@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';
@ -7705,59 +7700,32 @@ class AppLocalizationsEn extends AppLocalizations {
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';
}
String get 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.';
@override
String privacyEditsNotes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count notes',
one: '1 note',
);
return '$_temp0';
}
String get privacyEditCard => 'Flashcard';
@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';
}
String get privacyEditNote => 'Note';
@override
String privacyEditsPdfs(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count PDFs',
one: '1 PDF',
);
return '$_temp0';
}
String get privacyEditPath => 'Learning path';
@override
String privacyEditsStudysets(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count studysets',
one: '1 studyset',
);
return '$_temp0';
}
String get privacyEditStep => 'Step';
@override
String get privacyEditPdf => 'PDF';
@override
String get privacyEditStudyset => 'Studyset';
@override
String get privacyEditAnswer => 'Answer';
@override
String get privacyEditNoText => '(no text)';
@override
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/privacy_ledger.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';
/// 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
/// 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 {
/// private; or the instance), each with what it says: locked until the
/// person, having seen them, takes them in ([adoptUnsigned], the final
/// reading of 22 Oct).
Future<List<UnsignedEdit>> unsignedIn(String studysetId) async {
final ids = {for (final id in await scopeOf(studysetId)) _uuid(id)};
final counts = <String, int>{};
void count(
String kind,
final edits = <UnsignedEdit>[];
void add(
UnsignedEditKind kind,
String table,
String id,
String bound,
Envelope e,
) {
Envelope e, {
String column = 'payload',
}) {
if (e.keyId != null || e.payload.lengthInBytes == 0) {
return;
}
if (open(e, table: table, id: id, bound: bound) case Readable(
trusted: false,
)) {
counts[kind] = (counts[kind] ?? 0) + 1;
if (open(e, table: table, id: id, bound: bound, column: column)
case Readable(trusted: false, :final payload)) {
edits.add(
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,
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,
nonce: r.nonce,
payload: r.payload,
@ -727,11 +744,13 @@ class Privacy(
_db,
where: (t) => t.cardId.inSet(cards) & t.keyId.equals(null),
)) {
count('cards', 'card_revision', r.id!.uuid, 'card:${r.cardId.uuid}', (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
add(
UnsignedEditKind.card,
'card_revision',
r.id!.uuid,
'card:${r.cardId.uuid}',
(keyId: r.keyId, nonce: r.nonce, payload: r.payload),
);
}
final notes = {
for (final note in await protocol.Note.db.find(
@ -744,27 +763,25 @@ class Privacy(
_db,
where: (t) => t.noteId.inSet(notes) & t.keyId.equals(null),
)) {
count('notes', 'note_revision', r.id!.uuid, 'note:${r.noteId.uuid}', (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
add(
UnsignedEditKind.note,
'note_revision',
r.id!.uuid,
'note:${r.noteId.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',
add(
UnsignedEditKind.path,
'learning_path',
r.id!.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 = {
@ -778,41 +795,105 @@ class Privacy(
_db,
where: (t) => t.stepId.inSet(steps) & t.keyId.equals(null),
)) {
count(
'paths',
add(
UnsignedEditKind.step,
'learning_path_step_revision',
r.id!.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(
_db,
where: (t) => t.studysetId.inSet(ids) & t.keyId.equals(null),
)) {
count('pdfs', 'pdf_source', r.id!.uuid, 'studyset:${r.studysetId.uuid}', (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
add(
UnsignedEditKind.pdf,
'pdf_source',
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
/// are sealed with the tree's key as theirs. Returns how many rows or
/// What a payload says, for the person to see before taking it in: the
/// 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.
Future<int> adoptUnsigned(String studysetId) async {
Future<int> adoptUnsigned(
String studysetId,
List<UnsignedEdit> listed,
) async {
final keyId = _privateKeyOf(studysetId);
if (keyId == null || !keys.canEncrypt) {
throw const PrivacyRefused(PrivacyRefusal.keysMissing);
}
_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
@ -981,6 +1062,7 @@ class Privacy(
String? keyId, {
bool adopt = false,
bool foreign = false,
Map<String, ByteData> adoptOnly = const {},
}) => _rewrites.run(() async {
final done = Completer<void>();
_rewriting = done.future;
@ -994,6 +1076,7 @@ class Privacy(
cardIds: null,
keyId: keyId,
adopt: adopt,
adoptOnly: adoptOnly,
foreign: foreign,
top: studysetId,
);
@ -1047,6 +1130,7 @@ class Privacy(
cardIds: cardIds,
keyId: keyId,
adopt: adopt,
adoptOnly: const {},
foreign: true,
top: null,
);
@ -1063,6 +1147,7 @@ class Privacy(
required Set<String>? cardIds,
required String? keyId,
required bool adopt,
required Map<String, ByteData> adoptOnly,
required bool foreign,
required String? top,
}) async {
@ -1073,6 +1158,7 @@ class Privacy(
cardIds: cardIds,
keyId: keyId,
adopt: adopt,
adoptOnly: adoptOnly,
foreign: foreign,
top: top,
written: written,
@ -1100,6 +1186,7 @@ class Privacy(
required Set<String>? cardIds,
required String? keyId,
required bool adopt,
required Map<String, ByteData> adoptOnly,
required bool foreign,
required String? top,
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.
final rows = <_Row>[
for (final r in cardRevisions)
@ -1276,7 +1370,8 @@ class Privacy(
trusted ||
adopt ||
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.
@ -1461,7 +1556,13 @@ class Privacy(
switch (opened) {
case Readable(:final payload, :final trusted)
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)) {
_keepItem(item, keyId);
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 '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
/// 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
@ -52,12 +54,13 @@ abstract interface class StudysetGateway() {
/// account's keys (ADR 0019, D8).
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);
/// The edits in the private studyset [id] written readable without the
/// account's signature, each with what it says; locked until taken in.
Future<List<UnsignedEdit>> unsignedEditsOf(String id);
/// Seals the edits [unsignedEditsOf] counts as the person's.
Future<int> adoptUnsignedEdits(String id);
/// Seals exactly the edits [listed] ([unsignedEditsOf]) as the person's,
/// 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
/// 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_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/privacy/model/unsigned_edit.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_payload.dart';
@ -131,14 +132,15 @@ class StudysetRepository(final StudysetGateway _gateway) {
/// account's keys (ADR 0019, D8).
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) =>
/// The edits in the private studyset [id] written readable without the
/// account's signature, each with what it says (the final reading of 22
/// Oct).
Future<List<UnsignedEdit>> 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);
/// Takes exactly the edits [listed] in as the person's, sealed.
Future<int> adoptUnsignedEdits(String id, List<UnsignedEdit> listed) async {
final left = await _gateway.adoptUnsignedEdits(id, listed);
await _refresh();
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_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/model/unsigned_edit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
@ -219,12 +220,12 @@ class SyncStudysetGateway(
bool get canMakePrivate => _privacy.keys.canEncrypt;
@override
Future<Map<String, int>> unsignedEditsOf(String id) =>
Future<List<UnsignedEdit>> unsignedEditsOf(String id) =>
_privacy.unsignedIn(id);
@override
Future<int> adoptUnsignedEdits(String id) async {
final left = await _privacy.adoptUnsigned(id);
Future<int> adoptUnsignedEdits(String id, List<UnsignedEdit> listed) async {
final left = await _privacy.adoptUnsigned(id, listed);
_sync.requestSync();
return left;
}

View file

@ -1,5 +1,6 @@
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/privacy/model/unsigned_edit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -77,11 +78,12 @@ Future<void> applyStudysetPrivacy(
// 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 &&
if (unsigned.isNotEmpty &&
context.mounted &&
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);
@ -92,28 +94,49 @@ 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.
/// Asks whether to take in the [unsigned] edits written readable without
/// the account's signature while the studyset was made private, each shown
/// 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(
BuildContext context,
Map<String, int> unsigned,
List<UnsignedEdit> 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),
];
String kindOf(UnsignedEditKind kind) => switch (kind) {
UnsignedEditKind.card => l10n.privacyEditCard,
UnsignedEditKind.note => l10n.privacyEditNote,
UnsignedEditKind.path => l10n.privacyEditPath,
UnsignedEditKind.step => l10n.privacyEditStep,
UnsignedEditKind.pdf => l10n.privacyEditPdf,
UnsignedEditKind.studyset => l10n.privacyEditStudyset,
UnsignedEditKind.answer => l10n.privacyEditAnswer,
};
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(LucideIcons.shieldAlert),
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: [
TextButton(
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: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/content/rich_content.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/privacy/model/unsigned_edit.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/model/person_settings.dart';
@ -130,9 +133,16 @@ void main() {
expect(scope, (studysetId: 'studyset-1'));
});
testWidgets('Finish asks before taking in edits nobody signed, saying how '
'many of each kind', (tester) async {
testWidgets('Finish asks before taking in edits nobody signed, showing '
'each with what it says', (tester) async {
bool? answer;
UnsignedEdit edit(UnsignedEditKind kind, String preview) => UnsignedEdit(
kind: kind,
table: 'card_revision',
id: preview,
preview: preview,
payload: ByteData(0),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: appLocalizationsDelegates,
@ -140,7 +150,11 @@ void main() {
builder: (context) => TextButton(
onPressed: () async => answer = await confirmAdoptingEdits(
context,
{'cards': 2, 'notes': 1},
[
edit(UnsignedEditKind.card, '犬 · dog'),
edit(UnsignedEditKind.note, 'Week 3'),
edit(UnsignedEditKind.answer, ''),
],
),
child: const Text('finish'),
),
@ -150,7 +164,11 @@ void main() {
await tester.tap(find.text('finish'));
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.pumpAndSettle();
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/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/model/unsigned_edit.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/studyset/data/sync_studyset_gateway.dart';
@ -659,10 +660,13 @@ void main() {
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();
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();
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 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(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')]));
expect(
(await protocol.CardRevision.db.findById(
db,
protocol.UuidValue.withValidation(listed.single.id),
))!.keyId,
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: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';
/// A [StudysetGateway] in memory: ids are `studyset-1`, `studyset-2`, …, and
@ -77,18 +78,18 @@ class FakeStudysetGateway() implements StudysetGateway {
@override
bool keysMissing = false;
/// The unsigned edits each studyset holds, by kind.
final unsigned = <String, Map<String, int>>{};
/// The unsigned edits each studyset holds.
final unsigned = <String, List<UnsignedEdit>>{};
/// The studysets whose unsigned edits were taken in.
final adopted = <String>[];
@override
Future<Map<String, int>> unsignedEditsOf(String id) async =>
unsigned[id] ?? const {};
Future<List<UnsignedEdit>> unsignedEditsOf(String id) async =>
unsigned[id] ?? const [];
@override
Future<int> adoptUnsignedEdits(String id) async {
Future<int> adoptUnsignedEdits(String id, List<UnsignedEdit> listed) async {
adopted.add(id);
unsigned.remove(id);
return 0;