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:
parent
75398dca2c
commit
991bef47eb
13 changed files with 518 additions and 0 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue