refactor(content,core,app,editor,learn): Learn's content in pure Dart, for its agent
ADR 0044's D49 (amended in building it): what reads and seals Learn's content moves out of the Flutter packages, so Learn's agent, which runs without Flutter, shares it rather than repeating the privacy rules. packages/halkyon_content (new, pure Dart) takes from halkyon_app the envelope and its cipher, SealedFile, UnlockedAccountKeys, shared spaces' gateway with SharedKeys and KeyPins, FileCache and FileServerGateway, with their fakes and tests; halkyon_app exports it, so no app changes. Its rich_text.dart takes RichContent from halkyon_editor, which exports it. newAccountKeys makes an agent's key pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a synced gateway asks of the sync, which SyncService implements. apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy, sync, payloads and the models, codecs, gateways and repositories of studysets, cards, notes, tags and feedback, with what they reach of files, learning paths, citations and study options; the app imports them from there. A subject's icon, colour and name stay in the app (SubjectLook); what only the app shows (progress, exam coverage, the card browser, deletions' waits) stays too. Nothing changes for people using the apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
82c777b1a5
commit
d09625d43e
83 changed files with 3517 additions and 2007 deletions
|
|
@ -0,0 +1,49 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// An asset row as stored: the file's id, SHA-256 (lowercase hex), media type
|
||||
/// and size, and its payload, opened. A file of a private studyset (ADR
|
||||
/// 0019) is sealed with its tree's key, `keyId`; the row's hash and size are
|
||||
/// then the sealed file's, and `locked` where that key doesn't open here
|
||||
/// (its payload then blank).
|
||||
typedef AssetRow = ({
|
||||
String id,
|
||||
String sha256,
|
||||
String mimeType,
|
||||
int byteSize,
|
||||
ByteData payload,
|
||||
String? keyId,
|
||||
bool locked,
|
||||
});
|
||||
|
||||
/// The person's asset rows, one per file of their content (ADR 0004,
|
||||
/// rule 5); the files themselves are on the server and in the
|
||||
/// device's cache.
|
||||
abstract interface class AssetGateway() {
|
||||
/// The row [id]; null if there is none.
|
||||
Future<AssetRow?> byId(String id);
|
||||
|
||||
/// A row of the file with [sha256]; null if there is none.
|
||||
Future<AssetRow?> bySha256(String sha256);
|
||||
|
||||
/// Every row, sealed or readable.
|
||||
Future<List<AssetRow>> all();
|
||||
|
||||
/// An id for a new row, made before it is stored, as a sealed file is
|
||||
/// sealed for its row.
|
||||
String newId();
|
||||
|
||||
/// Stores a new row [id], its payload sealed with [keyId] (readable
|
||||
/// without), and returns its id.
|
||||
Future<String> insert({
|
||||
required String id,
|
||||
required String sha256,
|
||||
required String mimeType,
|
||||
required int byteSize,
|
||||
required ByteData payload,
|
||||
String? keyId,
|
||||
});
|
||||
|
||||
/// Blanks the row [id]'s payload, then deletes the row: its file goes from
|
||||
/// the server once nothing refers to it any more.
|
||||
Future<void> delete(String id);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_core/core/content/payload.dart';
|
||||
|
||||
// A file's content as its synced asset row stores it (see core/content/payload);
|
||||
// the hash, media type and size are columns of their own.
|
||||
//
|
||||
// Schema 1 holds the name the file came with.
|
||||
// Schema 2 adds, for a file of a private studyset (ADR 0019), `plain`: the
|
||||
// SHA-256 of the file before it was sealed, which the row's own hash, of
|
||||
// the sealed file, doesn't tell; it lives only in this payload, sealed too.
|
||||
|
||||
/// The schema this app writes for a readable file.
|
||||
const assetSchemaVersion = 1;
|
||||
|
||||
/// The newest schema this app reads.
|
||||
const _newestAssetSchema = 2;
|
||||
|
||||
/// What a file's payload holds.
|
||||
typedef AssetFileInfo = ({String fileName, String? plainSha256});
|
||||
|
||||
/// Encodes a file's [fileName] into its payload; for a sealed file also the
|
||||
/// SHA-256 of what was sealed ([plainSha256]).
|
||||
ByteData encodeAssetPayload({
|
||||
required String fileName,
|
||||
String? plainSha256,
|
||||
}) => plainSha256 == null
|
||||
? encodePayload(assetSchemaVersion, {'fileName': fileName})
|
||||
: encodePayload(2, {'fileName': fileName, 'plain': plainSha256});
|
||||
|
||||
/// Decodes a file's payload into the name it came with.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content written
|
||||
/// by a newer app version in a schema this one doesn't know.
|
||||
String decodeAssetPayload(ByteData payload) =>
|
||||
decodeAssetInfo(payload).fileName;
|
||||
|
||||
/// Decodes a file's payload: the name it came with, and for a sealed file
|
||||
/// the SHA-256 of what was sealed.
|
||||
///
|
||||
/// Throws a [FormatException] as [decodeAssetPayload] does.
|
||||
AssetFileInfo decodeAssetInfo(ByteData payload) {
|
||||
final json = decodePayload(payload, newestSchema: _newestAssetSchema);
|
||||
if (json case {'fileName': final String fileName}) {
|
||||
return (
|
||||
fileName: fileName,
|
||||
plainSha256: switch (json['plain']) {
|
||||
final String plain => plain,
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a file payload.');
|
||||
}
|
||||
|
|
@ -0,0 +1,437 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_content/halkyon_content.dart'
|
||||
show FileCache, FileLimitReached, FileRoom, FileServerGateway, isSha256;
|
||||
import 'package:halkyon_core/halkyon_core.dart'
|
||||
show CoalescingRunner, SerialQueue;
|
||||
import 'package:halkyon_learn_core/core/asset/data/asset_gateway.dart';
|
||||
import 'package:halkyon_learn_core/core/asset/data/asset_payload.dart';
|
||||
import 'package:halkyon_learn_core/core/asset/data/asset_sealing.dart';
|
||||
import 'package:halkyon_learn_core/core/asset/model/stored_file.dart';
|
||||
import 'package:halkyon_learn_core/core/privacy/data/tree_files.dart';
|
||||
|
||||
/// The files of the person's content (ADR 0004, rule 5): an asset row each,
|
||||
/// synced like all content, and the file itself on this device and the
|
||||
/// server, by its SHA-256.
|
||||
///
|
||||
/// A file added here is kept on this device and waits to be uploaded; the
|
||||
/// uploads run whenever `uploadTriggers` fires (after each sync, so the row
|
||||
/// is usually on the server before its file), one at a time, and what fails
|
||||
/// waits for the next time. A file this device lacks is downloaded when asked
|
||||
/// for, checked against its hash and kept.
|
||||
///
|
||||
/// A file of a private studyset (ADR 0019, D5) is sealed with its tree's key
|
||||
/// for its row before it is hashed ([AssetSealing]): the row's hash and the
|
||||
/// file uploaded (`encrypted`) are the sealed file's, and the instance never
|
||||
/// sees it readable. This device keeps the sealed file under its hash, to
|
||||
/// upload and check, and the opened one under the hash of what was sealed,
|
||||
/// which only the row's sealed payload tells, for what reads files by path.
|
||||
/// The same file in a private and a readable studyset, or in two private
|
||||
/// trees, is a file per key.
|
||||
class AssetRepository(
|
||||
final AssetGateway _rows,
|
||||
final FileServerGateway _server,
|
||||
final FileCache _files,
|
||||
final AssetSealing _sealing, {
|
||||
Stream<void> uploadTriggers = const Stream.empty(),
|
||||
|
||||
/// Whether files may go up and come down now: not while sync holds them
|
||||
/// back on mobile data (Halkyon's ADR 0042); always where not said.
|
||||
final bool Function() _filesAllowed = _always,
|
||||
}) implements TreeFiles {
|
||||
this {
|
||||
_triggers = uploadTriggers.listen((_) {
|
||||
// A sync may have brought rows: the index is read anew.
|
||||
_sealedIndex = null;
|
||||
unawaited(uploadPending());
|
||||
});
|
||||
}
|
||||
|
||||
/// The sealed rows by their key and the hash of what they hold
|
||||
/// (`<keyId>|<sha256>`), read once until rows change, so adding many files
|
||||
/// doesn't open every row for each (the reading of 22 Oct).
|
||||
Map<String, AssetRow>? _sealedIndex;
|
||||
|
||||
/// The largest file the server takes, of any kind but a PDF.
|
||||
static const maxBytes = 20 * 1024 * 1024;
|
||||
|
||||
/// The largest PDF the server takes (decided 2026-10-03; 100 MB since
|
||||
/// 2026-11-09).
|
||||
static const maxPdfBytes = 100 * 1024 * 1024;
|
||||
|
||||
/// A PDF's media type.
|
||||
static const pdfMediaType = 'application/pdf';
|
||||
|
||||
/// The largest file of [mimeType] the server takes.
|
||||
static int maxBytesOf(String mimeType) =>
|
||||
mimeType == pdfMediaType ? maxPdfBytes : maxBytes;
|
||||
|
||||
late final StreamSubscription<void> _triggers;
|
||||
|
||||
/// Adding one file at a time, so a file added twice at once is one row.
|
||||
final _adding = SerialQueue();
|
||||
|
||||
/// The downloads under way, by hash, so a file asked for twice at once is
|
||||
/// downloaded once.
|
||||
final _downloads = <String, Future<Uint8List?>>{};
|
||||
|
||||
/// Uploads what waits, one round at a time: a call during a round runs
|
||||
/// one more after it.
|
||||
late final _uploads = CoalescingRunner(
|
||||
_uploadAll,
|
||||
onError: (error, _) =>
|
||||
log('Uploading files failed', name: 'Assets', error: error),
|
||||
);
|
||||
|
||||
/// Whether it stopped: no round starts any more.
|
||||
var _stopped = false;
|
||||
|
||||
/// Adds [bytes], a file named [fileName] of [mimeType] in the studyset
|
||||
/// [studysetId] (sealed with its tree's key where it is private; readable
|
||||
/// without a studyset), and returns the id of its asset row: the row of
|
||||
/// the same file under the same key if there is one, else a new one.
|
||||
/// Throws an [ArgumentError] for an empty file or one too large for its
|
||||
/// kind ([maxBytesOf]).
|
||||
Future<String> add(
|
||||
Uint8List bytes, {
|
||||
required String fileName,
|
||||
required String mimeType,
|
||||
required String? studysetId,
|
||||
}) async => await addWithKey(
|
||||
bytes,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
keyId: studysetId == null ? null : await _sealing.keyToWriteIn(studysetId),
|
||||
);
|
||||
|
||||
/// Adds [bytes] as [add] does, for the item [itemId] of [kind]: sealed
|
||||
/// with the key of the tree it is in, where that is private.
|
||||
Future<String> addToItem(
|
||||
Uint8List bytes, {
|
||||
required String fileName,
|
||||
required String mimeType,
|
||||
required ItemKind kind,
|
||||
required String itemId,
|
||||
}) async => await addWithKey(
|
||||
bytes,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
keyId: await _sealing.keyOfItem(kind, itemId),
|
||||
);
|
||||
|
||||
/// The id of the asset row of the file with [sha256] where it would be
|
||||
/// added for the item [itemId] of [kind]; null while there is none.
|
||||
Future<String?> idInItem(
|
||||
String sha256, {
|
||||
required ItemKind kind,
|
||||
required String itemId,
|
||||
}) async => await _idUnder(sha256, await _sealing.keyOfItem(kind, itemId));
|
||||
|
||||
@override
|
||||
Future<String> addWithKey(
|
||||
Uint8List bytes, {
|
||||
required String fileName,
|
||||
required String mimeType,
|
||||
required String? keyId,
|
||||
}) async {
|
||||
if (bytes.isEmpty || bytes.length > maxBytesOf(mimeType)) {
|
||||
throw ArgumentError.value(bytes.length, 'bytes', 'is out of range');
|
||||
}
|
||||
return await _adding.run(() async {
|
||||
final plain = sha256.convert(bytes).toString();
|
||||
await _files.write(plain, bytes);
|
||||
if (keyId == null) {
|
||||
if (await _rows.bySha256(plain) case final row?
|
||||
when row.keyId == null) {
|
||||
return row.id;
|
||||
}
|
||||
final id = await _rows.insert(
|
||||
id: _rows.newId(),
|
||||
sha256: plain,
|
||||
mimeType: mimeType,
|
||||
byteSize: bytes.length,
|
||||
payload: encodeAssetPayload(fileName: fileName),
|
||||
);
|
||||
// Every new row asks the server, even for a file uploaded before:
|
||||
// the server may have pruned it once no row referred to it.
|
||||
await _files.markPending(plain);
|
||||
return id;
|
||||
}
|
||||
if (await _sealedRowOf(plain, keyId) case final row?) {
|
||||
return row.id;
|
||||
}
|
||||
final id = _rows.newId();
|
||||
final sealed = _sealing.sealFile(bytes, keyId: keyId, assetId: id);
|
||||
final hash = sha256.convert(sealed).toString();
|
||||
await _files.write(hash, sealed);
|
||||
await _rows.insert(
|
||||
id: id,
|
||||
sha256: hash,
|
||||
mimeType: mimeType,
|
||||
byteSize: sealed.length,
|
||||
payload: encodeAssetPayload(fileName: fileName, plainSha256: plain),
|
||||
keyId: keyId,
|
||||
);
|
||||
// Read anew next time, with this row.
|
||||
_sealedIndex = null;
|
||||
await _files.markPending(hash);
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
/// The row of the file whose content before sealing has [plain] as its
|
||||
/// hash, sealed with [keyId]; null while there is none.
|
||||
Future<AssetRow?> _sealedRowOf(String plain, String keyId) async {
|
||||
final index = _sealedIndex ??= {
|
||||
for (final row in await _rows.all())
|
||||
if (row.keyId case final rowKey? when !row.locked)
|
||||
if (_plainOf(row) case final rowPlain?) '$rowKey|$rowPlain': row,
|
||||
};
|
||||
return index['$keyId|$plain'];
|
||||
}
|
||||
|
||||
/// The hash of what [row]'s sealed file holds; null for a readable row,
|
||||
/// or one whose payload doesn't say.
|
||||
static String? _plainOf(AssetRow row) {
|
||||
try {
|
||||
return switch (decodeAssetInfo(row.payload).plainSha256) {
|
||||
final plain? when isSha256(plain) => plain,
|
||||
_ => null,
|
||||
};
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The id of the asset row of the file with [sha256] (as it is before any
|
||||
/// sealing) where it would be added for the studyset [studysetId]: sealed
|
||||
/// with its tree's key, or readable; null while the person has none.
|
||||
Future<String?> idOf(String sha256, {required String? studysetId}) async {
|
||||
final keyId = studysetId == null
|
||||
? null
|
||||
: await _sealing.keyOfStudyset(studysetId);
|
||||
return await _idUnder(sha256, keyId);
|
||||
}
|
||||
|
||||
/// The id of the row of the file with [sha256] sealed with [keyId], or
|
||||
/// readable without one.
|
||||
Future<String?> _idUnder(String sha256, String? keyId) async {
|
||||
if (keyId != null) {
|
||||
return (await _sealedRowOf(sha256, keyId))?.id;
|
||||
}
|
||||
return switch (await _rows.bySha256(sha256)) {
|
||||
final row? when row.keyId == null => row.id,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> keyIdOf(String id) async => (await _rows.byId(id))?.keyId;
|
||||
|
||||
@override
|
||||
Future<bool> exists(String id) async => await _rows.byId(id) != null;
|
||||
|
||||
/// The key of the tree the item [id] of [kind] is in; null while
|
||||
/// readable.
|
||||
Future<String?> keyOfItem(ItemKind kind, String id) =>
|
||||
_sealing.keyOfItem(kind, id);
|
||||
|
||||
@override
|
||||
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
|
||||
String id,
|
||||
) async => switch (await fileOf(id)) {
|
||||
final file? => (
|
||||
bytes: file.bytes,
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
),
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// The file of the asset row [id]: from this device, else downloaded.
|
||||
/// Null when it can't be had now: no such row, no connection, or not
|
||||
/// uploaded yet.
|
||||
Future<StoredFile?> fileOf(String id) async {
|
||||
final row = await _rows.byId(id);
|
||||
if (row == null || row.locked || !isSha256(row.sha256)) {
|
||||
return null;
|
||||
}
|
||||
if (row.keyId != null) {
|
||||
return await _sealedFileOf(row);
|
||||
}
|
||||
final bytes =
|
||||
await _files.read(row.sha256) ??
|
||||
await (_downloads[row.sha256] ??=
|
||||
_download(
|
||||
id,
|
||||
row.sha256,
|
||||
).whenComplete(() {
|
||||
_downloads.remove(row.sha256);
|
||||
}));
|
||||
if (bytes == null) {
|
||||
return null;
|
||||
}
|
||||
String fileName;
|
||||
try {
|
||||
fileName = decodeAssetPayload(row.payload);
|
||||
} on FormatException {
|
||||
fileName = '';
|
||||
}
|
||||
return StoredFile(
|
||||
bytes: bytes,
|
||||
mimeType: row.mimeType,
|
||||
fileName: fileName,
|
||||
path: _files.pathOf(row.sha256),
|
||||
);
|
||||
}
|
||||
|
||||
/// The file of the sealed [row], opened: as this device keeps it opened,
|
||||
/// else its sealed file (kept, or downloaded), checked against the row's
|
||||
/// hash, opened for the row, and checked against the hash of what was
|
||||
/// sealed.
|
||||
Future<StoredFile?> _sealedFileOf(AssetRow row) async {
|
||||
final plain = _plainOf(row);
|
||||
if (plain == null) {
|
||||
return null;
|
||||
}
|
||||
var bytes = await _files.read(plain);
|
||||
if (bytes == null) {
|
||||
final sealed =
|
||||
await _files.read(row.sha256) ??
|
||||
await (_downloads[row.sha256] ??= _download(row.id, row.sha256)
|
||||
.whenComplete(() {
|
||||
// A block, not an arrow: the future removed is this one,
|
||||
// and returned it would be waited for by itself.
|
||||
_downloads.remove(row.sha256);
|
||||
}));
|
||||
final opened = sealed == null
|
||||
? null
|
||||
: _sealing.openFile(sealed, assetId: row.id);
|
||||
if (opened == null || sha256.convert(opened).toString() != plain) {
|
||||
return null;
|
||||
}
|
||||
await _files.write(plain, opened);
|
||||
bytes = opened;
|
||||
}
|
||||
return StoredFile(
|
||||
bytes: bytes,
|
||||
mimeType: row.mimeType,
|
||||
fileName: decodeAssetInfo(row.payload).fileName,
|
||||
path: _files.pathOf(plain),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _download(String id, String hash) async {
|
||||
if (!_filesAllowed()) {
|
||||
// As without a connection: what this device lacks waits.
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final bytes = await _server.download(id);
|
||||
if (bytes == null || sha256.convert(bytes).toString() != hash) {
|
||||
return null;
|
||||
}
|
||||
await _files.write(hash, bytes);
|
||||
return bytes;
|
||||
} on Object catch (error) {
|
||||
log('Downloading a file failed', name: 'Assets', error: error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads the files that wait; what fails waits for the next time. One
|
||||
/// run at a time: a call during a run runs once more after it.
|
||||
Future<void> uploadPending() => _uploads.run();
|
||||
|
||||
Future<void> _uploadAll() async {
|
||||
if (_stopped || !_filesAllowed()) {
|
||||
return;
|
||||
}
|
||||
for (final hash in await _files.pendingUploads()) {
|
||||
try {
|
||||
if (!await _upload(hash)) {
|
||||
continue;
|
||||
}
|
||||
await _files.clearPending(hash);
|
||||
} on FileLimitReached catch (error) {
|
||||
// The others wouldn't fit either: they stay on this device, and
|
||||
// the next round asks again, once room may have been made.
|
||||
log('No room for more files', name: 'Assets', error: error);
|
||||
return;
|
||||
} on Object catch (error) {
|
||||
// Without a connection the others fail too: next time.
|
||||
log('Uploading a file failed', name: 'Assets', error: error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads the file with [hash]; whether it no longer waits.
|
||||
Future<bool> _upload(String hash) async {
|
||||
final bytes = await _files.read(hash);
|
||||
if (bytes == null) {
|
||||
// Gone from this device: nothing to upload, ever.
|
||||
return true;
|
||||
}
|
||||
final row = await _rows.bySha256(hash);
|
||||
if (row == null) {
|
||||
// No live row names it any more, as one a private studyset's rewrite
|
||||
// replaced: never uploaded, least of all readable (the reading of 22
|
||||
// Oct).
|
||||
return true;
|
||||
}
|
||||
// A sealed file says so, as only a readable one is looked into.
|
||||
final encrypted = row.keyId != null;
|
||||
final description = await _server.startUpload(
|
||||
hash,
|
||||
bytes.length,
|
||||
encrypted: encrypted,
|
||||
);
|
||||
if (description == null) {
|
||||
return true;
|
||||
}
|
||||
return await _server.upload(description, bytes) &&
|
||||
await _server.finishUpload(hash, encrypted: encrypted);
|
||||
}
|
||||
|
||||
/// How much room the person's files take on the server, and may take; null
|
||||
/// when that can't be asked now (no connection).
|
||||
Future<FileRoom?> room() async {
|
||||
try {
|
||||
return await _server.room();
|
||||
} on Exception catch (error) {
|
||||
log('Asking for the room of files failed', name: 'Assets', error: error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The size of the file [id] in bytes, from its row; null without one.
|
||||
Future<int?> byteSizeOf(String id) async => (await _rows.byId(id))?.byteSize;
|
||||
|
||||
/// Deletes the row of the file [id], blanked first: the file goes from the
|
||||
/// server once nothing refers to it, and stops taking the person's room.
|
||||
/// Only for files no content refers to any more.
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
final row = await _rows.byId(id);
|
||||
await _rows.delete(id);
|
||||
_sealedIndex = null;
|
||||
// Its upload, if still waiting, is no longer owed (the reading of 22
|
||||
// Oct), unless another row holds the same file.
|
||||
if (row != null && await _rows.bySha256(row.sha256) == null) {
|
||||
await _files.clearPending(row.sha256);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops uploading; an upload under way finishes first.
|
||||
Future<void> dispose() async {
|
||||
await _triggers.cancel();
|
||||
_stopped = true;
|
||||
await _uploads.idle;
|
||||
}
|
||||
}
|
||||
|
||||
bool _always() => true;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// The kinds of item a file can be added to without naming its studyset.
|
||||
enum ItemKind() {
|
||||
card,
|
||||
note,
|
||||
step,
|
||||
}
|
||||
|
||||
/// How the files of a private studyset are sealed (ADR 0019, D5): with its
|
||||
/// tree's key, for the asset row they belong to, so a file moved to another
|
||||
/// row never opens. `Privacy` does it; tests fake it.
|
||||
abstract interface class AssetSealing() {
|
||||
/// The key [studysetId]'s tree is encrypted with; null while readable.
|
||||
Future<String?> keyOfStudyset(String studysetId);
|
||||
|
||||
/// [keyOfStudyset] for a file about to be added there; throws where this
|
||||
/// device may write nothing (it lacks the keys of an account that has
|
||||
/// them, the final reading of 22 Oct).
|
||||
Future<String?> keyToWriteIn(String studysetId);
|
||||
|
||||
/// The key of the tree the item [id] of [kind] is in; null while
|
||||
/// readable, or for an item that isn't there.
|
||||
Future<String?> keyOfItem(ItemKind kind, String id);
|
||||
|
||||
/// [plain] sealed with the key [keyId] for the asset row [assetId], as it
|
||||
/// is stored and uploaded. Throws a [StateError] for a key not open here.
|
||||
Uint8List sealFile(
|
||||
Uint8List plain, {
|
||||
required String keyId,
|
||||
required String assetId,
|
||||
});
|
||||
|
||||
/// What [sealed] holds, for the asset row [assetId]; null where it
|
||||
/// doesn't open here (its key isn't open, it was changed, or it belongs
|
||||
/// to another row).
|
||||
Uint8List? openFile(Uint8List sealed, {required String assetId});
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A file of the person's content (an image or a sound), on this device and
|
||||
/// ready to show or play.
|
||||
class const StoredFile({
|
||||
/// Its content.
|
||||
required final Uint8List bytes,
|
||||
|
||||
/// Its media type, e.g. 'image/png' or 'audio/mpeg'.
|
||||
required final String mimeType,
|
||||
|
||||
/// The name it came with.
|
||||
required final String fileName,
|
||||
|
||||
/// Where it is on this device, for players that read files.
|
||||
required final String path,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [bytes, mimeType, fileName, path];
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import 'dart:async';
|
||||
|
||||
/// A value a repository reads again after every change, handed to everyone
|
||||
/// following it (decided 2026-10-17): one starting to follow gets the value
|
||||
/// last read at once, then every value read after it with the others. Only
|
||||
/// while there is none, before the first read, after one failed or once
|
||||
/// nobody followed it, does one starting to follow have it read; everyone
|
||||
/// following gets what that read brings. Before, every screen opening had
|
||||
/// everything read again and handed to every screen open.
|
||||
///
|
||||
/// While nobody follows it, nothing is kept, so a repository need not read
|
||||
/// after a change then ([hasListener]).
|
||||
class LatestValue<T>(
|
||||
/// Has the value read and handed on ([add], [addError]).
|
||||
final void Function() _read,
|
||||
) {
|
||||
late final _controller = StreamController<T>.broadcast(
|
||||
onCancel: () => _last = null,
|
||||
);
|
||||
|
||||
/// The value last read; null before the first read, after one failed and
|
||||
/// while nobody follows it.
|
||||
({T value})? _last;
|
||||
|
||||
/// The value last read, then every value read after it; a read that fails
|
||||
/// arrives as an error.
|
||||
Stream<T> get stream => Stream.multi((listener) {
|
||||
final subscription = _controller.stream.listen(
|
||||
listener.add,
|
||||
onError: listener.addError,
|
||||
);
|
||||
listener.onCancel = subscription.cancel;
|
||||
if (_last case (:final value)?) {
|
||||
listener.add(value);
|
||||
} else {
|
||||
_read();
|
||||
}
|
||||
});
|
||||
|
||||
/// The value last read; null before the first read, after one failed and
|
||||
/// while nobody follows it.
|
||||
T? get value => _last?.value;
|
||||
|
||||
/// Whether anyone follows it: while nobody does, there is no need to read.
|
||||
bool get hasListener => _controller.hasListener;
|
||||
|
||||
bool get isClosed => _controller.isClosed;
|
||||
|
||||
/// Hands [value], just read, to everyone following, and keeps it for
|
||||
/// those starting to; with nobody following, it isn't kept, as changes
|
||||
/// from now on may not be read.
|
||||
void add(T value) {
|
||||
_last = hasListener ? (value: value) : null;
|
||||
_controller.add(value);
|
||||
}
|
||||
|
||||
/// Hands a read that failed to everyone following; the next one starting
|
||||
/// to follow has it read again.
|
||||
void addError(Object error, StackTrace stackTrace) {
|
||||
_last = null;
|
||||
_controller.addError(error, stackTrace);
|
||||
}
|
||||
|
||||
Future<void> close() => _controller.close();
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_core/halkyon_core.dart' show CoalescingRunner;
|
||||
|
||||
/// [read]'s value now and after every event of [changes], for each listener
|
||||
/// on its own: one read at a time, changes during a read making one more
|
||||
/// after it rather than one each, so an older value never overtakes a newer
|
||||
/// one. A failed read is logged under [logName] as reading [what] and
|
||||
/// arrives as an error; the next change reads again.
|
||||
Stream<T> watchReads<T>(
|
||||
Stream<void> changes,
|
||||
Future<T> Function() read, {
|
||||
required String what,
|
||||
required String logName,
|
||||
}) => Stream.multi((listener) {
|
||||
var cancelled = false;
|
||||
final reads = CoalescingRunner(
|
||||
() async {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
final value = await read();
|
||||
if (!cancelled) {
|
||||
listener.add(value);
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
log('Reading $what failed', name: logName, error: error);
|
||||
if (!cancelled) {
|
||||
listener.addError(error, stackTrace);
|
||||
}
|
||||
},
|
||||
);
|
||||
final subscription = changes.listen((_) => unawaited(reads.run()));
|
||||
listener.onCancel = () {
|
||||
cancelled = true;
|
||||
return subscription.cancel();
|
||||
};
|
||||
unawaited(reads.run());
|
||||
});
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What kind of work a source is, which its form asks for and its citation
|
||||
/// says (decided 2026-10-12).
|
||||
enum SourceKind() {
|
||||
/// A book, an article, a chapter: the default.
|
||||
work,
|
||||
|
||||
/// A page on the web, [Source.container] the website's name.
|
||||
website,
|
||||
|
||||
/// A YouTube video, its [Source.authors] the channel; played in the app
|
||||
/// where it can be.
|
||||
video,
|
||||
}
|
||||
|
||||
/// A work cited: by a step of a learning path, or in a studyset's
|
||||
/// bibliography. Every part is optional and plain text, as the person wrote
|
||||
/// it; `formatCitation` puts them together in the style the device uses.
|
||||
final class const Source({
|
||||
/// What kind of work it is.
|
||||
final SourceKind kind = SourceKind.work,
|
||||
|
||||
/// Who wrote it, one full name per line (or separated by `;`), as
|
||||
/// "Jonathan Gregory Pevsner"; each style abbreviates and orders them
|
||||
/// itself.
|
||||
final String authors = '',
|
||||
|
||||
/// When it appeared, as "2020"; empty when unknown.
|
||||
final String year = '',
|
||||
|
||||
/// Its title.
|
||||
final String title = '',
|
||||
|
||||
/// What it is a part of: the journal, book or website it appeared in, with
|
||||
/// pages where they matter, as "Nature 591, 12–15"; empty for a work of
|
||||
/// its own, such as a book. The styles set a part's title apart from the
|
||||
/// whole's by this.
|
||||
final String container = '',
|
||||
|
||||
/// Who published it, as "Wiley-Blackwell".
|
||||
final String publisher = '',
|
||||
|
||||
/// The book's ISBN, as written.
|
||||
final String isbn = '',
|
||||
|
||||
/// Where to find it on the web.
|
||||
final String url = '',
|
||||
|
||||
/// The citation as a file brought it, for what couldn't be split into the
|
||||
/// parts above; shown as it is while those are empty.
|
||||
final String raw = '',
|
||||
}) with Equatable {
|
||||
/// The source in [json], as [toJson] writes it.
|
||||
///
|
||||
/// Each part is text; a number reads as its text (a file written by hand
|
||||
/// may give the year as one). A [FormatException] for anything else.
|
||||
factory fromJson(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
throw const FormatException('A source is an object.');
|
||||
}
|
||||
String part(String key) => switch (json[key]) {
|
||||
null => '',
|
||||
final String text => text,
|
||||
final num number => '$number',
|
||||
_ => throw FormatException('A source with a malformed $key.'),
|
||||
};
|
||||
return Source(
|
||||
// A kind this app doesn't know yet reads as a work: its parts still
|
||||
// cite it.
|
||||
kind:
|
||||
SourceKind.values
|
||||
.where((kind) => kind.name == json['kind'])
|
||||
.firstOrNull ??
|
||||
SourceKind.work,
|
||||
authors: part('authors'),
|
||||
year: part('year'),
|
||||
title: part('title'),
|
||||
container: part('container'),
|
||||
publisher: part('publisher'),
|
||||
isbn: part('isbn'),
|
||||
url: part('url'),
|
||||
raw: part('raw'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether nothing of it is written.
|
||||
bool get isEmpty =>
|
||||
authors.isEmpty &&
|
||||
year.isEmpty &&
|
||||
title.isEmpty &&
|
||||
container.isEmpty &&
|
||||
publisher.isEmpty &&
|
||||
isbn.isEmpty &&
|
||||
url.isEmpty &&
|
||||
raw.isEmpty;
|
||||
|
||||
/// This source without spaces around its parts, nor around each author.
|
||||
Source trimmed() => Source(
|
||||
kind: kind,
|
||||
authors: authors
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.where((line) => line.isNotEmpty)
|
||||
.join('\n'),
|
||||
year: year.trim(),
|
||||
title: title.trim(),
|
||||
container: container.trim(),
|
||||
publisher: publisher.trim(),
|
||||
isbn: isbn.trim(),
|
||||
url: url.trim(),
|
||||
raw: raw.trim(),
|
||||
);
|
||||
|
||||
/// This source as JSON, each part left out when empty, and its kind
|
||||
/// when it isn't a work.
|
||||
Map<String, Object?> toJson() => {
|
||||
if (kind != SourceKind.work) 'kind': kind.name,
|
||||
if (authors.isNotEmpty) 'authors': authors,
|
||||
if (year.isNotEmpty) 'year': year,
|
||||
if (title.isNotEmpty) 'title': title,
|
||||
if (container.isNotEmpty) 'container': container,
|
||||
if (publisher.isNotEmpty) 'publisher': publisher,
|
||||
if (isbn.isNotEmpty) 'isbn': isbn,
|
||||
if (url.isNotEmpty) 'url': url,
|
||||
if (raw.isNotEmpty) 'raw': raw,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
kind,
|
||||
authors,
|
||||
year,
|
||||
title,
|
||||
container,
|
||||
publisher,
|
||||
isbn,
|
||||
url,
|
||||
raw,
|
||||
];
|
||||
}
|
||||
42
apps/learn/halkyon_learn_core/lib/core/content/payload.dart
Normal file
42
apps/learn/halkyon_learn_core/lib/core/content/payload.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
// The content payload of synced rows: UTF-8 JSON with a `schemaVersion`. A new
|
||||
// content field raises the version instead of migrating a synced table, which
|
||||
// would lock older app versions out of syncing (ADR 0002, rule 3).
|
||||
|
||||
/// The payload deleted content keeps: the sync engine keeps deleted rows until
|
||||
/// their space is deleted, so the content is blanked first (ADR 0002, rule 2).
|
||||
final redactedPayload = ByteData(0);
|
||||
|
||||
/// [json] as a payload in [schemaVersion].
|
||||
ByteData encodePayload(int schemaVersion, Map<String, Object?> json) =>
|
||||
ByteData.sublistView(
|
||||
utf8.encode(jsonEncode({'schemaVersion': schemaVersion, ...json})),
|
||||
);
|
||||
|
||||
/// The JSON in [payload], its `schemaVersion` included.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including a schema newer
|
||||
/// than [newestSchema]: content written by a newer app version.
|
||||
Map<String, Object?> decodePayload(
|
||||
ByteData payload, {
|
||||
required int newestSchema,
|
||||
}) {
|
||||
final Object? json;
|
||||
try {
|
||||
json = jsonDecode(utf8.decode(Uint8List.sublistView(payload)));
|
||||
} on FormatException {
|
||||
throw const FormatException('Not a content payload.');
|
||||
}
|
||||
if (json case {'schemaVersion': final int version}) {
|
||||
if (version > newestSchema) {
|
||||
throw FormatException(
|
||||
'Content in schema $version, but this app only knows schemas up to '
|
||||
'$newestSchema.',
|
||||
);
|
||||
}
|
||||
return json as Map<String, Object?>;
|
||||
}
|
||||
throw const FormatException('Not a content payload.');
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/// A page of a PDF that content is tied to: a card made from it, a note
|
||||
/// about it. The PDF is named by its row's id; pages count from 1.
|
||||
typedef PdfPageRef = ({String pdfId, int page});
|
||||
|
||||
/// [ref] as content payloads store it: `{"pdf", "page"}`.
|
||||
Map<String, Object?> pdfPageRefToJson(PdfPageRef ref) => {
|
||||
'pdf': ref.pdfId,
|
||||
'page': ref.page,
|
||||
};
|
||||
|
||||
/// The page [json] names, as [pdfPageRefToJson] wrote it; null for null.
|
||||
/// Throws a [FormatException] for anything else.
|
||||
PdfPageRef? pdfPageRefFromJson(Object? json) => switch (json) {
|
||||
null => null,
|
||||
{'pdf': final String pdfId, 'page': final int page} when page > 0 => (
|
||||
pdfId: pdfId,
|
||||
page: page,
|
||||
),
|
||||
_ => throw const FormatException('A malformed page of a PDF.'),
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Where an item of a copy came from (ADR 0005, rule 4): the published
|
||||
/// studyset ([publication], its public id), the item there (its card's,
|
||||
/// note's, step's, path's, PDF's or studyset's id in the release), and the
|
||||
/// release whose content the item last took ([version]). A later release
|
||||
/// is taken from that one on, so what the learner changed since stays. An
|
||||
/// item the author withdrew in [version] stays the learner's, [withdrawn].
|
||||
final class const PublishedRef({
|
||||
required final String publication,
|
||||
required final String item,
|
||||
required final int version,
|
||||
final bool withdrawn = false,
|
||||
}) with Equatable {
|
||||
/// This origin as of release [version], whose content the item took.
|
||||
PublishedRef taking(int version) =>
|
||||
PublishedRef(publication: publication, item: item, version: version);
|
||||
|
||||
/// This origin with the item withdrawn in release [version].
|
||||
PublishedRef withdrawnIn(int version) => PublishedRef(
|
||||
publication: publication,
|
||||
item: item,
|
||||
version: version,
|
||||
withdrawn: true,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [publication, item, version, withdrawn];
|
||||
}
|
||||
|
||||
/// [ref] as content payloads store it: `{"publication", "item", "version"}`,
|
||||
/// and `"withdrawn": true` for an item the author withdrew.
|
||||
Map<String, Object?> publishedRefToJson(PublishedRef ref) => {
|
||||
'publication': ref.publication,
|
||||
'item': ref.item,
|
||||
'version': ref.version,
|
||||
if (ref.withdrawn) 'withdrawn': true,
|
||||
};
|
||||
|
||||
/// The origin [json] names, as [publishedRefToJson] wrote it; null for null.
|
||||
/// Throws a [FormatException] for anything else.
|
||||
PublishedRef? publishedRefFromJson(Object? json) => switch (json) {
|
||||
null => null,
|
||||
{
|
||||
'publication': final String publication,
|
||||
'item': final String item,
|
||||
'version': final int version,
|
||||
}
|
||||
when version > 0 =>
|
||||
PublishedRef(
|
||||
publication: publication,
|
||||
item: item,
|
||||
version: version,
|
||||
withdrawn: json['withdrawn'] == true,
|
||||
),
|
||||
_ => throw const FormatException('A malformed published origin.'),
|
||||
};
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Content whose fields merge separately when devices edited it while
|
||||
/// offline (ADR 0003): a card's sides, a note's title and body.
|
||||
abstract interface class MergeableContent<
|
||||
F,
|
||||
C extends MergeableContent<F, C>
|
||||
>() {
|
||||
/// The value of [field].
|
||||
Object? operator [](F field);
|
||||
|
||||
/// This content with [field] set to [value], which must be of the field's
|
||||
/// kind.
|
||||
C withField(F field, Object? value);
|
||||
}
|
||||
|
||||
/// One version of some content, never changed once written (ADR 0001,
|
||||
/// rule 2): editing appends a revision whose [parents] are the versions it
|
||||
/// replaces.
|
||||
abstract interface class Revision<C>() {
|
||||
/// A UUID v7, made on the device that wrote it; ids sort by creation.
|
||||
String get id;
|
||||
|
||||
/// The revisions this one replaces: none for the first, several when it
|
||||
/// resolves a conflict.
|
||||
Set<String> get parents;
|
||||
|
||||
/// The content as of this revision.
|
||||
C get content;
|
||||
}
|
||||
|
||||
/// Content merged from its revisions by [mergeRevisionsOf].
|
||||
final class const Merged<F, C>({
|
||||
/// The current content. For a field in [conflicts], the newest version
|
||||
/// shows until the person chooses.
|
||||
required final C content,
|
||||
|
||||
/// The current heads: what an edit of the merged content replaces.
|
||||
required final Set<String> heads,
|
||||
|
||||
/// The fields changed differently on different devices, with every
|
||||
/// version, oldest first.
|
||||
final Map<F, List<Object?>> conflicts = const {},
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [content, heads, conflicts];
|
||||
}
|
||||
|
||||
/// Merges [revisions] of one piece of content field by field (decided
|
||||
/// 2026-09-30, ADR 0003), or null without any.
|
||||
///
|
||||
/// The heads are the revisions no other revision replaces. One head is the
|
||||
/// current version. Several heads, from edits on different devices while
|
||||
/// offline, merge field by field against their newest common ancestor: a
|
||||
/// field only one of them changed takes that change, a field they changed
|
||||
/// differently is a conflict. Nothing is merged silently inside a field.
|
||||
/// Revisions whose parents haven't arrived yet count as they are.
|
||||
Merged<F, C>? mergeRevisionsOf<F, C extends MergeableContent<F, C>>(
|
||||
Iterable<Revision<C>> revisions, {
|
||||
required List<F> fields,
|
||||
}) {
|
||||
final byId = {for (final revision in revisions) revision.id: revision};
|
||||
if (byId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final replaced = {for (final revision in byId.values) ...revision.parents};
|
||||
// UUID v7 ids sort by creation (ADR 0001, rule 7): the newest head is last.
|
||||
final heads = [
|
||||
for (final revision in byId.values)
|
||||
if (!replaced.contains(revision.id)) revision,
|
||||
]..sort((a, b) => a.id.compareTo(b.id));
|
||||
if (heads.length == 1) {
|
||||
return Merged(content: heads.single.content, heads: {heads.single.id});
|
||||
}
|
||||
|
||||
final base = _commonAncestor(heads, byId)?.content;
|
||||
var content = heads.last.content;
|
||||
final conflicts = <F, List<Object?>>{};
|
||||
for (final field in fields) {
|
||||
// What the heads changed this field to, oldest first, without repeats.
|
||||
final changed = <Object?>[];
|
||||
for (final head in heads) {
|
||||
final version = head.content[field];
|
||||
if (!_same(version, base?[field]) &&
|
||||
!changed.any((each) => _same(each, version))) {
|
||||
changed.add(version);
|
||||
}
|
||||
}
|
||||
switch (changed) {
|
||||
case []:
|
||||
// Unchanged everywhere; the newest head holds it already.
|
||||
break;
|
||||
case [final only]:
|
||||
content = content.withField(field, only);
|
||||
default:
|
||||
conflicts[field] = changed;
|
||||
content = content.withField(field, changed.last);
|
||||
}
|
||||
}
|
||||
return Merged(
|
||||
content: content,
|
||||
heads: {for (final head in heads) head.id},
|
||||
conflicts: conflicts,
|
||||
);
|
||||
}
|
||||
|
||||
/// The newest revision all [heads] descend from, or null if they share none.
|
||||
///
|
||||
/// Of the common ancestors, only those no other common ancestor descends from
|
||||
/// qualify; of those (possible after criss-cross merges), the newest.
|
||||
Revision<C>? _commonAncestor<C>(
|
||||
List<Revision<C>> heads,
|
||||
Map<String, Revision<C>> byId,
|
||||
) {
|
||||
Set<String> lineage(String id) {
|
||||
final seen = <String>{};
|
||||
final pending = [id];
|
||||
while (pending.isNotEmpty) {
|
||||
final next = pending.removeLast();
|
||||
if (byId.containsKey(next) && seen.add(next)) {
|
||||
pending.addAll(byId[next]!.parents);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
final common = heads
|
||||
.map((head) => lineage(head.id))
|
||||
.reduce((a, b) => a.intersection(b));
|
||||
final nearest = [
|
||||
for (final candidate in common)
|
||||
if (!common.any(
|
||||
(other) => other != candidate && lineage(other).contains(candidate),
|
||||
))
|
||||
candidate,
|
||||
]..sort();
|
||||
return nearest.isEmpty ? null : byId[nearest.last];
|
||||
}
|
||||
|
||||
/// Whether two values of a field are the same: lists by their elements.
|
||||
bool _same(Object? a, Object? b) => switch ((a, b)) {
|
||||
(final List<Object?> a, final List<Object?> b) =>
|
||||
a.length == b.length &&
|
||||
[for (var i = 0; i < a.length; i++) a[i] == b[i]].every((same) => same),
|
||||
_ => a == b,
|
||||
};
|
||||
|
||||
/// What taking an update into content kept as revisions came to: a
|
||||
/// dictionary's correction into a card, a release into a copy's card, note
|
||||
/// or step.
|
||||
enum UpdateTaken() {
|
||||
/// Nothing: the content can't take it.
|
||||
none,
|
||||
|
||||
/// Taken whole.
|
||||
taken,
|
||||
|
||||
/// Taken, but the person changed a side the update changed too: a
|
||||
/// conflict to settle.
|
||||
toSettle,
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// What kind of data key a row of `content_key` holds (ADR 0019).
|
||||
enum ContentKeyKind() {
|
||||
/// A private studyset's: everything in its tree.
|
||||
studyset,
|
||||
|
||||
/// The person's own: their tags and settings.
|
||||
personal,
|
||||
|
||||
/// No key to write with: a signed record that a studyset was made
|
||||
/// readable (ADR 0019, the reading of 22 Oct).
|
||||
readable,
|
||||
}
|
||||
|
||||
/// A data key as its row keeps it: sealed to the person's account. Only its
|
||||
/// sealed ID is trusted, which says what the key is for (`ContentKeyId`);
|
||||
/// its kind, studyset and time are the instance's to change.
|
||||
typedef ContentKeyRow = ({
|
||||
String id,
|
||||
String dataKeyId,
|
||||
ContentKeyKind kind,
|
||||
String? studysetId,
|
||||
Uint8List sealedKey,
|
||||
DateTime createdAt,
|
||||
});
|
||||
|
||||
/// The person's data keys (ADR 0019), synced in their space.
|
||||
abstract interface class ContentKeyGateway() {
|
||||
/// Every key kept, of every kind; rows of a kind this app doesn't know are
|
||||
/// left out.
|
||||
Future<List<ContentKeyRow>> all();
|
||||
|
||||
/// Keeps a new key.
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required ContentKeyKind kind,
|
||||
required String? studysetId,
|
||||
required Uint8List sealedKey,
|
||||
required DateTime createdAt,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What a data key is for, as its ID says (ADR 0019, the reading of 22 Oct).
|
||||
enum ContentKeyScope() {
|
||||
/// A private studyset's key, for everything in its tree.
|
||||
studyset,
|
||||
|
||||
/// The person's own key, for their tags and settings.
|
||||
personal,
|
||||
|
||||
/// No key to write with, but a signed record that the studyset it names
|
||||
/// was made readable then, which ends its earlier keys.
|
||||
readable,
|
||||
}
|
||||
|
||||
/// A data key's ID, which says what the key is for: its scope, the
|
||||
/// studyset it names, and when it was made. The ID is sealed with the key
|
||||
/// and signed by the account (halkyon_keys' `sealDataKey` opens a key only
|
||||
/// under the ID it was sealed with), so the instance can relabel no key:
|
||||
/// the `content_key` row's own columns are never trusted.
|
||||
final class const ContentKeyId({
|
||||
/// What the key is for.
|
||||
required final ContentKeyScope scope,
|
||||
|
||||
/// The studyset a studyset's key or a readable record names.
|
||||
required final String? studysetId,
|
||||
|
||||
/// When it was made, in milliseconds since the epoch (UTC), on the device
|
||||
/// that made it: what orders a studyset's records.
|
||||
required final int madeAt,
|
||||
|
||||
/// The ID as it is stored.
|
||||
required final String value,
|
||||
}) with Equatable {
|
||||
/// A new ID for [scope], naming [studysetId] for a studyset's key or
|
||||
/// readable record, made now.
|
||||
///
|
||||
/// Made later than [after] (milliseconds), the latest record known about
|
||||
/// that studyset, whatever this device's clock says: a clock behind would
|
||||
/// make a record that never counts (the short reading of 22 Oct).
|
||||
factory next(ContentKeyScope scope, {String? studysetId, int? after}) {
|
||||
final random = Random.secure();
|
||||
final nonce = base64Url
|
||||
.encode([for (var i = 0; i < 12; i++) random.nextInt(256)])
|
||||
.replaceAll('=', '');
|
||||
final now = clock.now().toUtc().millisecondsSinceEpoch;
|
||||
final ms = after != null && after >= now ? after + 1 : now;
|
||||
final value = switch (scope) {
|
||||
ContentKeyScope.personal => 'p1.$ms.$nonce',
|
||||
ContentKeyScope.studyset => 's1.${studysetId!}.$ms.$nonce',
|
||||
ContentKeyScope.readable => 'r1.${studysetId!}.$ms.$nonce',
|
||||
};
|
||||
return ContentKeyId(
|
||||
scope: scope,
|
||||
studysetId: studysetId,
|
||||
madeAt: ms,
|
||||
value: value,
|
||||
);
|
||||
}
|
||||
|
||||
/// [value] read as an ID; null for one of another form (a key made
|
||||
/// before IDs said what they are for, or one this app doesn't know),
|
||||
/// which never counts as a fact about privacy.
|
||||
static ContentKeyId? parse(String value) {
|
||||
final parts = value.split('.');
|
||||
switch (parts) {
|
||||
case ['p1', final ms, final _]:
|
||||
final madeAt = int.tryParse(ms);
|
||||
return madeAt == null
|
||||
? null
|
||||
: ContentKeyId(
|
||||
scope: ContentKeyScope.personal,
|
||||
studysetId: null,
|
||||
madeAt: madeAt,
|
||||
value: value,
|
||||
);
|
||||
case [final kind, final studyset, final ms, final _]
|
||||
when (kind == 's1' || kind == 'r1') && studyset.isNotEmpty:
|
||||
final madeAt = int.tryParse(ms);
|
||||
return madeAt == null
|
||||
? null
|
||||
: ContentKeyId(
|
||||
scope: kind == 's1'
|
||||
? ContentKeyScope.studyset
|
||||
: ContentKeyScope.readable,
|
||||
studysetId: studyset,
|
||||
madeAt: madeAt,
|
||||
value: value,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [scope, studysetId, madeAt, value];
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:halkyon_core/halkyon_core.dart';
|
||||
import 'package:halkyon_learn_core/core/privacy/data/content_key_gateway.dart';
|
||||
import 'package:halkyon_learn_core/core/privacy/data/content_key_id.dart';
|
||||
import 'package:halkyon_learn_core/core/privacy/data/privacy_ledger.dart';
|
||||
|
||||
/// The person's data keys on this device (ADR 0019): each private
|
||||
/// studyset's and their personal key, sealed to their account and synced,
|
||||
/// opened here with the account's keys. Without those keys on the device (an
|
||||
/// account signed in without its password here, or one that has none yet,
|
||||
/// D8) nothing opens and nothing new is made private.
|
||||
///
|
||||
/// What a key is for is its sealed ID's to say ([ContentKeyId]), never its
|
||||
/// row's: so the keys that open are also the signed facts about which
|
||||
/// studysets are private ([ledger] keeps the latest seen of each), and the
|
||||
/// instance can neither relabel a key nor make one up (the reading of 22
|
||||
/// Oct).
|
||||
class ContentKeys(
|
||||
final ContentKeyGateway _gateway,
|
||||
|
||||
/// Seals and opens with the keys; [ContentKeys] owns it.
|
||||
final ContentCipher cipher,
|
||||
|
||||
/// The account's keys on this device; null where none are.
|
||||
final Future<UnlockedAccountKeys?> Function() _accountKeys, {
|
||||
|
||||
/// Whether the register says the account has keys (a password); null
|
||||
/// where it can't be asked. Without it, the keys kept are the clue.
|
||||
final Future<bool?> Function()? registerHasKeys,
|
||||
|
||||
/// What this device has seen of the person's privacy; one in memory
|
||||
/// unless given.
|
||||
PrivacyLedger? seen,
|
||||
}) {
|
||||
/// What this device has seen of the person's privacy, kept in their
|
||||
/// folder.
|
||||
final PrivacyLedger ledger =
|
||||
seen ?? PrivacyLedger(MemoryPrivacyLedgerStore());
|
||||
|
||||
var _unlocked = false;
|
||||
var _rows = const <ContentKeyRow>[];
|
||||
|
||||
/// The rows whose key wouldn't open, by row: a forged row never shadows
|
||||
/// the real key of the same ID, and keys not open yet are tried again.
|
||||
final _refusedRows = <String>{};
|
||||
|
||||
/// The personal keys open here, by their IDs.
|
||||
final _personal = <ContentKeyId>[];
|
||||
|
||||
late final _refreshing = CoalescingRunner(
|
||||
_refresh,
|
||||
onError: (error, st) =>
|
||||
log('Opening data keys failed', name: 'Privacy', error: error),
|
||||
);
|
||||
|
||||
/// How many keys were opened here so far: rows that didn't open may with
|
||||
/// more.
|
||||
int opened = 0;
|
||||
|
||||
/// Whether this device holds the account's keys, so new content can be
|
||||
/// made private; known once [refresh] ran.
|
||||
bool get canEncrypt => _unlocked;
|
||||
|
||||
/// Whether the person has keys at all. A device without the account's
|
||||
/// keys on such an account writes nothing that would be private (the
|
||||
/// reading of 22 Oct).
|
||||
///
|
||||
/// Any sign is enough, and none is taken back (the final reading of 22
|
||||
/// Oct): the keys open here, the register saying so (once it said yes),
|
||||
/// key rows kept, records or sealed tags and settings this device saw. The
|
||||
/// register's answer comes through the instance unsigned, so its "no"
|
||||
/// never outweighs the rest, and it is asked again until it says yes: a
|
||||
/// password set on another device later counts here too. Rows the instance
|
||||
/// plants on an account without keys only make this device refuse to
|
||||
/// write, never write readable.
|
||||
bool get accountHasKeys =>
|
||||
_unlocked ||
|
||||
ledger.registerHasKeys == true ||
|
||||
_rows.isNotEmpty ||
|
||||
ledger.keysSeen ||
|
||||
ledger.studysetsWithFacts.isNotEmpty ||
|
||||
ledger.personalSealed;
|
||||
|
||||
/// Opens the keys kept that aren't open yet: as the person's data opens,
|
||||
/// and once a sync may have brought more.
|
||||
Future<void> refresh() => _refreshing.run();
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (!_unlocked) {
|
||||
final account = await _accountKeys();
|
||||
if (account != null) {
|
||||
await cipher.unlock(account);
|
||||
_unlocked = true;
|
||||
}
|
||||
}
|
||||
// Asked only while nothing else says the account has keys: its yes
|
||||
// would change nothing then, and every read refreshes, so reads waited
|
||||
// on the instance, minutes on a poor connection (10 Nov 2026).
|
||||
if (!accountHasKeys && registerHasKeys != null) {
|
||||
try {
|
||||
// Kept only once it says yes; a no is asked again next time.
|
||||
if (await registerHasKeys!() == true) {
|
||||
ledger.registerHasKeys = true;
|
||||
}
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
'Asking whether the account has keys failed',
|
||||
name: 'Privacy',
|
||||
error: error.runtimeType,
|
||||
);
|
||||
}
|
||||
}
|
||||
_rows = await _gateway.all();
|
||||
// Remembered, so withholding them later changes nothing here.
|
||||
if (_rows.isNotEmpty) {
|
||||
ledger.keysSeen = true;
|
||||
}
|
||||
if (!_unlocked) {
|
||||
return;
|
||||
}
|
||||
for (final row in _rows) {
|
||||
if (_refusedRows.contains(row.id)) {
|
||||
continue;
|
||||
}
|
||||
final id = ContentKeyId.parse(row.dataKeyId);
|
||||
if (!cipher.has(row.dataKeyId)) {
|
||||
if (!cipher.openKey(row.dataKeyId, row.sealedKey)) {
|
||||
_refusedRows.add(row.id);
|
||||
continue;
|
||||
}
|
||||
opened++;
|
||||
}
|
||||
if (id != null) {
|
||||
_learn(id);
|
||||
}
|
||||
}
|
||||
// The signed records are back: a damaged ledger is whole again.
|
||||
ledger.damaged = false;
|
||||
}
|
||||
|
||||
/// Keeps what the open key [id] says: a studyset made private or
|
||||
/// readable, or the person's key.
|
||||
void _learn(ContentKeyId id) {
|
||||
switch (id.scope) {
|
||||
case ContentKeyScope.studyset:
|
||||
ledger.learnFact(id.studysetId!, (at: id.madeAt, keyId: id.value));
|
||||
case ContentKeyScope.readable:
|
||||
ledger.learnFact(id.studysetId!, (at: id.madeAt, keyId: null));
|
||||
case ContentKeyScope.personal:
|
||||
if (!_personal.contains(id)) {
|
||||
_personal.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the key [keyId] is open here.
|
||||
bool canOpen(String keyId) => cipher.has(keyId);
|
||||
|
||||
/// A new key for the private studyset [studysetId], kept and synced, and
|
||||
/// the fact that it is private from now on; its ID. Throws a
|
||||
/// [StateError] where this device can't encrypt.
|
||||
Future<String> newStudysetKey(String studysetId) => _newKey(
|
||||
ContentKeyId.next(
|
||||
ContentKeyScope.studyset,
|
||||
studysetId: studysetId,
|
||||
after: ledger.factOf(studysetId)?.at,
|
||||
),
|
||||
);
|
||||
|
||||
/// Keeps and syncs the signed record that [studysetId] is readable from
|
||||
/// now on, which ends its earlier keys. Throws a [StateError] where this
|
||||
/// device can't encrypt.
|
||||
Future<void> recordReadable(String studysetId) async {
|
||||
await _newKey(
|
||||
ContentKeyId.next(
|
||||
ContentKeyScope.readable,
|
||||
studysetId: studysetId,
|
||||
after: ledger.factOf(studysetId)?.at,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The person's key for their tags and settings, made the first time;
|
||||
/// null where this device can't encrypt. Two devices making one at once
|
||||
/// each keep theirs, every device opening both; the oldest is written
|
||||
/// with, by when its sealed ID says it was made.
|
||||
Future<String?> personalKey() async {
|
||||
if (!_unlocked) {
|
||||
return null;
|
||||
}
|
||||
if (_personal.isEmpty) {
|
||||
return await _newKey(ContentKeyId.next(ContentKeyScope.personal));
|
||||
}
|
||||
final oldest = [..._personal]
|
||||
..sort(
|
||||
(a, b) => a.madeAt != b.madeAt
|
||||
? a.madeAt.compareTo(b.madeAt)
|
||||
: a.value.compareTo(b.value),
|
||||
);
|
||||
return oldest.first.value;
|
||||
}
|
||||
|
||||
/// Whether a personal key is open here, or this device saw one: from
|
||||
/// then on, a readable tag or settings row is no longer trusted.
|
||||
bool get hasPersonalKey => _personal.isNotEmpty || ledger.personalSealed;
|
||||
|
||||
Future<String> _newKey(ContentKeyId id) async {
|
||||
if (!_unlocked) {
|
||||
throw StateError('The account keys are not on this device');
|
||||
}
|
||||
final createdAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
id.madeAt,
|
||||
isUtc: true,
|
||||
);
|
||||
final sealedKey = cipher.newKey(id.value);
|
||||
opened++;
|
||||
final kind = switch (id.scope) {
|
||||
ContentKeyScope.studyset => ContentKeyKind.studyset,
|
||||
ContentKeyScope.personal => ContentKeyKind.personal,
|
||||
ContentKeyScope.readable => ContentKeyKind.readable,
|
||||
};
|
||||
await _gateway.insert(
|
||||
dataKeyId: id.value,
|
||||
kind: kind,
|
||||
studysetId: id.studysetId,
|
||||
sealedKey: sealedKey,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
_rows = [
|
||||
..._rows,
|
||||
(
|
||||
id: id.value,
|
||||
dataKeyId: id.value,
|
||||
kind: kind,
|
||||
studysetId: id.studysetId,
|
||||
sealedKey: sealedKey,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
];
|
||||
_learn(id);
|
||||
return id.value;
|
||||
}
|
||||
|
||||
/// Wipes the keys kept open here, and writes what the device saw.
|
||||
Future<void> dispose() async {
|
||||
cipher.dispose();
|
||||
await ledger.flush();
|
||||
}
|
||||
}
|
||||
2493
apps/learn/halkyon_learn_core/lib/core/privacy/data/privacy.dart
Normal file
2493
apps/learn/halkyon_learn_core/lib/core/privacy/data/privacy.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,383 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:math' show Random;
|
||||
|
||||
import 'package:halkyon_core/halkyon_core.dart' show SerialQueue;
|
||||
|
||||
/// The latest signed fact this device has seen about a studyset's privacy
|
||||
/// (ADR 0019, the reading of 22 Oct): private with `keyId` since `at`, or
|
||||
/// made readable at `at` (`keyId` null).
|
||||
typedef TreeFact = ({int at, String? keyId});
|
||||
|
||||
/// A rewrite of a tree to another key that hasn't finished on this device:
|
||||
/// the studyset at its top, the key it goes to (null: readable), and whether
|
||||
/// it takes in readable rows nobody signed (only where the person made a
|
||||
/// readable tree private; never a sweep's, the final reading of 22 Oct), as
|
||||
/// it was begun, so a resumed one does no more than it was asked.
|
||||
typedef PendingRewrite = ({String studysetId, String? keyId, bool adopt});
|
||||
|
||||
/// Where the device keeps what it has seen of the person's privacy: in their
|
||||
/// folder, never synced, so the instance can't take it back by withholding
|
||||
/// or replaying rows. A fake in tests.
|
||||
abstract interface class PrivacyLedgerStore() {
|
||||
/// What was kept; null when nothing is, or it can't be read.
|
||||
Future<Map<String, Object?>?> read();
|
||||
|
||||
/// Keeps [ledger] in place of what was kept.
|
||||
Future<void> write(Map<String, Object?> ledger);
|
||||
}
|
||||
|
||||
/// [PrivacyLedgerStore] in a JSON file, written whole through a temporary
|
||||
/// file, so a crash leaves the old one.
|
||||
class FilePrivacyLedgerStore(final File _file) implements PrivacyLedgerStore {
|
||||
final _queue = SerialQueue();
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>?> read() async {
|
||||
final String text;
|
||||
try {
|
||||
text = await _file.readAsString();
|
||||
} on FileSystemException {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return jsonDecode(text) as Map<String, Object?>;
|
||||
} on Object catch (error) {
|
||||
// Kept aside, never overwritten as if there were none (the short
|
||||
// reading of 22 Oct): what it held may be all that stands between a
|
||||
// replayed row and a readable write.
|
||||
log(
|
||||
'What the device saw of privacy is unreadable',
|
||||
name: 'Privacy',
|
||||
error: error.runtimeType,
|
||||
);
|
||||
await _file.rename(
|
||||
'${_file.path}.damaged-${DateTime.now().millisecondsSinceEpoch}',
|
||||
);
|
||||
throw const FormatException('privacy.json is damaged');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(Map<String, Object?> ledger) => _queue.run(() async {
|
||||
await _file.parent.create(recursive: true);
|
||||
final temporary = File('${_file.path}.new');
|
||||
await temporary.writeAsString(jsonEncode(ledger), flush: true);
|
||||
await temporary.rename(_file.path);
|
||||
});
|
||||
}
|
||||
|
||||
/// [PrivacyLedgerStore] in memory, for tests.
|
||||
class MemoryPrivacyLedgerStore() implements PrivacyLedgerStore {
|
||||
/// What is kept.
|
||||
Map<String, Object?>? kept;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>?> read() async => kept;
|
||||
|
||||
@override
|
||||
Future<void> write(Map<String, Object?> ledger) async =>
|
||||
kept = jsonDecode(jsonEncode(ledger)) as Map<String, Object?>;
|
||||
}
|
||||
|
||||
/// What this device has seen of the person's privacy (ADR 0019, the reading
|
||||
/// of 22 Oct), which the instance can't take back: the latest signed fact
|
||||
/// about each studyset's privacy, the key each item was last seen sealed
|
||||
/// with, whether its tags and settings were sealed once, the highest
|
||||
/// settings counter seen, and the rewrites still to finish. Only ever moves
|
||||
/// forward, but by this device's own changes.
|
||||
class PrivacyLedger(final PrivacyLedgerStore _store) {
|
||||
final _facts = <String, TreeFact>{};
|
||||
final _items = <String, String>{};
|
||||
final _pending = <PendingRewrite>[];
|
||||
var _personalSealed = false;
|
||||
final _settingsFrontier = <String, Map<String, int>>{};
|
||||
final _foldedSettings = <String>{};
|
||||
String? _deviceId;
|
||||
bool? _registerHasKeys;
|
||||
var _keysSeen = false;
|
||||
|
||||
/// Whether what was kept couldn't be read: until the keys are opened
|
||||
/// again, which brings the signed records back, this device writes
|
||||
/// nothing private (the short reading of 22 Oct).
|
||||
bool damaged = false;
|
||||
|
||||
Future<void>? _saving;
|
||||
var _dirty = false;
|
||||
|
||||
/// Reads what was kept; once, before anything else.
|
||||
Future<void> open() async {
|
||||
final Map<String, Object?>? kept;
|
||||
try {
|
||||
kept = await _store.read();
|
||||
} on FormatException {
|
||||
damaged = true;
|
||||
return;
|
||||
}
|
||||
if (kept == null) {
|
||||
return;
|
||||
}
|
||||
if (kept['facts'] case final Map<String, Object?> facts) {
|
||||
for (final MapEntry(:key, :value) in facts.entries) {
|
||||
if (value case {'at': final int at, 'key': final String? keyId}) {
|
||||
_facts[key] = (at: at, keyId: keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kept['items'] case final Map<String, Object?> items) {
|
||||
for (final MapEntry(:key, :value) in items.entries) {
|
||||
if (value case final String keyId) {
|
||||
_items[key] = keyId;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kept['pending'] case final List<Object?> pending) {
|
||||
for (final item in pending) {
|
||||
if (item case {'studyset': final String id, 'key': final String? key}) {
|
||||
_pending.add((
|
||||
studysetId: id,
|
||||
keyId: key,
|
||||
adopt: item['adopt'] == true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_personalSealed = kept['personalSealed'] == true;
|
||||
Map<String, int> vectorOf(Map<String, Object?> vector) => {
|
||||
for (final MapEntry(:key, :value) in vector.entries)
|
||||
if (value case final int counter) key: counter,
|
||||
};
|
||||
if (kept['settingsFrontier'] case final Map<String, Object?> rows) {
|
||||
for (final MapEntry(key: row, :value) in rows.entries) {
|
||||
if (value case final Map<String, Object?> vector) {
|
||||
_settingsFrontier[row] = vectorOf(vector);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kept['foldedSettings'] case final List<Object?> folded) {
|
||||
_foldedSettings.addAll(folded.whereType<String>());
|
||||
}
|
||||
_deviceId = kept['device'] as String?;
|
||||
// A no kept by an earlier version is asked again.
|
||||
_registerHasKeys = kept['registerHasKeys'] == true ? true : null;
|
||||
_keysSeen = kept['keysSeen'] == true;
|
||||
}
|
||||
|
||||
/// The latest signed fact seen about [studysetId]'s privacy.
|
||||
TreeFact? factOf(String studysetId) => _facts[studysetId];
|
||||
|
||||
/// Keeps [fact] about [studysetId] if it is later than the one kept;
|
||||
/// whether it was.
|
||||
bool learnFact(String studysetId, TreeFact fact) {
|
||||
final known = _facts[studysetId];
|
||||
if (known != null && known.at >= fact.at) {
|
||||
return false;
|
||||
}
|
||||
_facts[studysetId] = fact;
|
||||
_changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Every studyset a fact is kept about.
|
||||
Iterable<String> get studysetsWithFacts => _facts.keys;
|
||||
|
||||
/// The key [item] (`card:<id>`, `studyset:<id>`, …) was last seen sealed
|
||||
/// with; null for one never seen sealed, or released.
|
||||
String? keyOfItem(String item) => _items[item];
|
||||
|
||||
/// Keeps that [item] was seen sealed with [keyId].
|
||||
void sealedWith(String item, String keyId) {
|
||||
if (_items[item] != keyId) {
|
||||
_items[item] = keyId;
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets [item]'s key: this device made it readable, or a signed
|
||||
/// record ended its key.
|
||||
void release(String item) {
|
||||
if (_items.remove(item) != null) {
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets every item kept under one of [keyIds]: a signed record made
|
||||
/// their tree readable.
|
||||
void releaseKeys(Set<String> keyIds) {
|
||||
final before = _items.length;
|
||||
_items.removeWhere((_, keyId) => keyIds.contains(keyId));
|
||||
if (_items.length != before) {
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this device sealed the person's readable tags and settings
|
||||
/// once their personal key existed, after which a readable one is no
|
||||
/// longer trusted.
|
||||
bool get personalSealed => _personalSealed;
|
||||
|
||||
set personalSealed(bool sealed) {
|
||||
if (_personalSealed != sealed) {
|
||||
_personalSealed = sealed;
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// This device's own name in the settings' version vectors: random, made
|
||||
/// once.
|
||||
String get deviceId {
|
||||
if (_deviceId case final id?) {
|
||||
return id;
|
||||
}
|
||||
final random = Random.secure();
|
||||
_deviceId = base64Url
|
||||
.encode([for (var i = 0; i < 9; i++) random.nextInt(256)])
|
||||
.replaceAll('=', '');
|
||||
_changed();
|
||||
return _deviceId!;
|
||||
}
|
||||
|
||||
/// The newest versions of the person's settings this device has seen (the
|
||||
/// final reading of 22 Oct): each row's vector (each device's count of
|
||||
/// writes) that no other seen vector is newer than. Two rows written at
|
||||
/// once on two devices are both here; a row folded into another, or
|
||||
/// written over, is not.
|
||||
Map<String, Map<String, int>> get settingsFrontier => {
|
||||
for (final MapEntry(:key, :value) in _settingsFrontier.entries)
|
||||
key: Map.unmodifiable(value),
|
||||
};
|
||||
|
||||
/// Keeps that the settings row [rowId] was seen at [vector]: it joins the
|
||||
/// newest, and every vector it is newer than leaves them.
|
||||
void sawSettingsVersion(String rowId, Map<String, int> vector) {
|
||||
final own = _settingsFrontier[rowId];
|
||||
if (own != null && _sameVector(own, vector)) {
|
||||
return;
|
||||
}
|
||||
_settingsFrontier
|
||||
..removeWhere((_, seen) => isOlderVersion(seen, than: vector))
|
||||
..[rowId] = {...vector};
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Whether [vector] is older than [than]: no count higher, and one lower.
|
||||
static bool isOlderVersion(
|
||||
Map<String, int> vector, {
|
||||
required Map<String, int> than,
|
||||
}) {
|
||||
final devices = {...vector.keys, ...than.keys};
|
||||
return devices.every((d) => (vector[d] ?? 0) <= (than[d] ?? 0)) &&
|
||||
devices.any((d) => (vector[d] ?? 0) < (than[d] ?? 0));
|
||||
}
|
||||
|
||||
static bool _sameVector(Map<String, int> a, Map<String, int> b) {
|
||||
final devices = {...a.keys, ...b.keys};
|
||||
return devices.every((d) => (a[d] ?? 0) == (b[d] ?? 0));
|
||||
}
|
||||
|
||||
/// Whether the settings row [rowId] was folded away here, so never read
|
||||
/// again, whatever the instance hands back (the final reading of 22 Oct).
|
||||
bool foldedSettings(String rowId) => _foldedSettings.contains(rowId);
|
||||
|
||||
/// Keeps that the settings row [rowId] was folded away here.
|
||||
void foldSettings(String rowId) {
|
||||
if (_foldedSettings.add(rowId)) {
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// True once the register said the account has keys; null until then (a
|
||||
/// no is never kept, the final reading of 22 Oct).
|
||||
bool? get registerHasKeys => _registerHasKeys;
|
||||
|
||||
set registerHasKeys(bool? has) {
|
||||
if (has != _registerHasKeys) {
|
||||
_registerHasKeys = has;
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this device ever saw the person's key rows: from then on the
|
||||
/// account has keys here, whatever the instance withholds later (the
|
||||
/// final reading of 22 Oct).
|
||||
bool get keysSeen => _keysSeen;
|
||||
|
||||
set keysSeen(bool seen) {
|
||||
if (seen && !_keysSeen) {
|
||||
_keysSeen = true;
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// The rewrites still to finish here, oldest first.
|
||||
List<PendingRewrite> get pending => List.unmodifiable(_pending);
|
||||
|
||||
/// Keeps that [rewrite] is under way, in place of any for its studyset.
|
||||
void begin(PendingRewrite rewrite) {
|
||||
_pending
|
||||
..removeWhere((p) => p.studysetId == rewrite.studysetId)
|
||||
..add(rewrite);
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Forgets [rewrite]: it finished.
|
||||
void finish(PendingRewrite rewrite) {
|
||||
if (_pending.remove(rewrite)) {
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes what changed, if anything did; done after each change anyway,
|
||||
/// one write at a time.
|
||||
Future<void> flush() async {
|
||||
while (_saving != null) {
|
||||
await _saving;
|
||||
}
|
||||
if (_dirty) {
|
||||
await _save();
|
||||
}
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
_dirty = true;
|
||||
_saving ??= _save().whenComplete(() => _saving = null);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
// Changes made while writing are written next.
|
||||
while (_dirty) {
|
||||
_dirty = false;
|
||||
try {
|
||||
await _store.write({
|
||||
'facts': {
|
||||
for (final MapEntry(:key, :value) in _facts.entries)
|
||||
key: {'at': value.at, 'key': value.keyId},
|
||||
},
|
||||
'items': _items,
|
||||
'pending': [
|
||||
for (final p in _pending)
|
||||
{'studyset': p.studysetId, 'key': p.keyId, 'adopt': p.adopt},
|
||||
],
|
||||
'personalSealed': _personalSealed,
|
||||
'settingsFrontier': _settingsFrontier,
|
||||
'foldedSettings': [..._foldedSettings],
|
||||
'device': ?_deviceId,
|
||||
'registerHasKeys': ?_registerHasKeys,
|
||||
'keysSeen': _keysSeen,
|
||||
});
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
'What the device saw of privacy was not kept',
|
||||
name: 'Privacy',
|
||||
error: error.runtimeType,
|
||||
);
|
||||
// Written with the next change, or the next flush (the final
|
||||
// reading of 22 Oct).
|
||||
_dirty = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:halkyon_core/halkyon_core.dart';
|
||||
import 'package:halkyon_core/halkyon_core.dart' show CoalescingRunner;
|
||||
import 'package:halkyon_learn_core/core/privacy/data/space_key_id.dart';
|
||||
|
||||
/// The keys of the shared spaces the person is a member of, on this device
|
||||
/// (Halkyon's ADR 0044, D3, D20, D22): opened as the members' devices seal
|
||||
/// them, sealed in turn to newcomers, and the latest of each space, which
|
||||
/// new writes there take.
|
||||
class SharedSpaceKeys(
|
||||
final SharedKeys _keys,
|
||||
final ContentCipher _cipher,
|
||||
|
||||
/// The shared spaces the person is a member of, by the sync engine's
|
||||
/// grants.
|
||||
final Future<Set<String>> Function() _spaces,
|
||||
) {
|
||||
/// The latest key known of each space, by its ID's time.
|
||||
final _latest = <String, SpaceKeyId>{};
|
||||
|
||||
StreamSubscription<void>? _syncs;
|
||||
|
||||
late final _upkeep = CoalescingRunner(
|
||||
_keepUp,
|
||||
onError: (error, st) => log(
|
||||
"Keeping shared spaces' keys failed",
|
||||
name: 'SharedSpaceKeys',
|
||||
error: error,
|
||||
stackTrace: st,
|
||||
),
|
||||
);
|
||||
|
||||
/// The key new writes in [space] take; null while none is open here.
|
||||
SpaceKeyId? latestOf(String space) => _latest[space];
|
||||
|
||||
/// Opens the keys sealed to the person in each of their shared spaces,
|
||||
/// seals those this device holds to members who lack them, and makes a
|
||||
/// new key where one is due: at the data's opening and after each sync.
|
||||
Future<void> upkeep() => _upkeep.run();
|
||||
|
||||
Future<void> _keepUp() async {
|
||||
for (final space in await _spaces()) {
|
||||
// One space failing (the instance refusing it, keys that changed)
|
||||
// holds back none of the others.
|
||||
try {
|
||||
final opened = await _keys.openMine(space);
|
||||
for (final keyId in opened.opened) {
|
||||
_learn(space, keyId);
|
||||
}
|
||||
await _keys.sealForNewcomers(space);
|
||||
// A member went: what is written from now on takes a key they
|
||||
// never get, which this device makes as one that writes (D22,
|
||||
// D33). The instance asks a Viewer's device for none.
|
||||
if (await _keys.rollDue(space)) {
|
||||
await newKey(space);
|
||||
}
|
||||
} on Object catch (error, st) {
|
||||
log(
|
||||
"Keeping a shared space's keys failed",
|
||||
name: 'SharedSpaceKeys',
|
||||
error: error,
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new key for [space], later than any known of it, sealed to its
|
||||
/// members and the latest from now on: the space's first, or the one a
|
||||
/// roll makes (D22). Throws what [SharedKeys.share] throws.
|
||||
Future<SpaceKeyId> newKey(String space) async {
|
||||
final id = SpaceKeyId.next(space, after: _latest[space]?.madeAt);
|
||||
_cipher.newKey(id.value);
|
||||
await _keys.share(space, id.value);
|
||||
_learn(space, id.value);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Keeps [keyId] as [space]'s latest where it is later. Only a key whose
|
||||
/// ID names [space] counts: the ID is sealed with the key, so the
|
||||
/// instance can't hand one space's key out as another's.
|
||||
void _learn(String space, String keyId) {
|
||||
final id = SpaceKeyId.parse(keyId);
|
||||
if (id == null || id.space != space) {
|
||||
return;
|
||||
}
|
||||
final latest = _latest[space];
|
||||
if (latest == null ||
|
||||
id.madeAt > latest.madeAt ||
|
||||
(id.madeAt == latest.madeAt && id.value.compareTo(latest.value) > 0)) {
|
||||
_latest[space] = id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the keys up now and after each of [syncs].
|
||||
void keepUpOn(Stream<void> syncs) {
|
||||
unawaited(upkeep());
|
||||
_syncs = syncs.listen((_) => unawaited(upkeep()));
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _syncs?.cancel();
|
||||
await _upkeep.idle;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A shared space's data key's ID (Halkyon's ADR 0044, D21, D22): the space
|
||||
/// it is for and when it was made. The ID is sealed with the key to each
|
||||
/// member (halkyon_keys' `sealDataKey` opens a key only under the ID it was
|
||||
/// sealed with), so the instance can't hand one space's key out as
|
||||
/// another's. Never a `content_key` row's: those are the person's own
|
||||
/// (`ContentKeyId` reads this form as none of its own).
|
||||
final class const SpaceKeyId({
|
||||
/// The shared space the key is for.
|
||||
required final String space,
|
||||
|
||||
/// When it was made, in milliseconds since the epoch (UTC), on the device
|
||||
/// that made it: the latest is the one new writes take (D22).
|
||||
required final int madeAt,
|
||||
|
||||
/// The ID as it is stored.
|
||||
required final String value,
|
||||
}) with Equatable {
|
||||
/// A new ID for [space]'s key, made now, later than [after] (milliseconds,
|
||||
/// the latest key known of the space) whatever this device's clock says.
|
||||
factory next(String space, {int? after}) {
|
||||
final random = Random.secure();
|
||||
final nonce = base64Url
|
||||
.encode([for (var i = 0; i < 12; i++) random.nextInt(256)])
|
||||
.replaceAll('=', '');
|
||||
final now = clock.now().toUtc().millisecondsSinceEpoch;
|
||||
final ms = after != null && after >= now ? after + 1 : now;
|
||||
return SpaceKeyId(space: space, madeAt: ms, value: 'h1.$space.$ms.$nonce');
|
||||
}
|
||||
|
||||
/// [value] read as a space key's ID; null for any other form.
|
||||
static SpaceKeyId? parse(String? value) {
|
||||
if (value case final value?) {
|
||||
if (value.split('.') case ['h1', final space, final ms, final nonce]
|
||||
when space.isNotEmpty && nonce.isNotEmpty) {
|
||||
if (int.tryParse(ms) case final madeAt?) {
|
||||
return SpaceKeyId(space: space, madeAt: madeAt, value: value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [space, madeAt, value];
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_core/halkyon_core.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_core/core/privacy/data/content_key_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [ContentKeyGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
class SyncContentKeyGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncRequests _sync,
|
||||
) implements ContentKeyGateway {
|
||||
@override
|
||||
Future<List<ContentKeyRow>> all() async => [
|
||||
for (final key in await protocol.ContentKey.db.find(
|
||||
_db,
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
if (ContentKeyKind.values.asNameMap()[key.kind] case final kind?)
|
||||
(
|
||||
id: key.id!.uuid,
|
||||
dataKeyId: key.dataKeyId,
|
||||
kind: kind,
|
||||
studysetId: key.studysetId?.uuid,
|
||||
sealedKey: Uint8List.sublistView(key.sealedKey),
|
||||
createdAt: key.createdAt,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required ContentKeyKind kind,
|
||||
required String? studysetId,
|
||||
required Uint8List sealedKey,
|
||||
required DateTime createdAt,
|
||||
}) async {
|
||||
await protocol.ContentKey.db.insertRow(
|
||||
_db,
|
||||
protocol.ContentKey(
|
||||
dataKeyId: dataKeyId,
|
||||
kind: kind.name,
|
||||
studysetId: studysetId == null
|
||||
? null
|
||||
: protocol.UuidValue.withValidation(studysetId),
|
||||
sealedKey: ByteData.sublistView(sealedKey),
|
||||
createdAt: createdAt.toUtc(),
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue