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:
parent
92ff7616a4
commit
c9c6533afc
14 changed files with 434 additions and 243 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue