feat(learn): sharing moves a studyset into a space of its own

Step 4 (d) of ADR 0044 (D14, D23, D27, D28), without its screens yet.

- `StudysetSharing`: refuses a studyset with a publication in its tree
  and a device that can't seal, before anything is made; makes the
  space, readies it, exports the tree with its progress, imports it
  under new IDs with its top placed in the space, and deletes the old
  one for good once the copy is whole. Failing or aborted, the space
  is deleted and the studyset stays as it was.
- `SyncSpaceSetup`: syncs until the person's membership of the new
  space came, then makes its first key and seals it to them.
- `Privacy` writes a top studyset placed in a space with that space's
  key; a studyset can be inserted under an ID chosen beforehand.
- The archive's export and import take `moved`, keeping each item's
  link to Explore and each card's origin.
- An import that fails midway takes back what it wrote, as an aborted
  one did.

Whats-New: An import that fails partway no longer leaves part of the studyset behind.
Whats-New-De: Ein Import, der unterwegs scheitert, lässt kein halbes Lernset mehr zurück.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 14:13:04 +01:00
commit d3e8b7d1c6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 1001 additions and 41 deletions

View file

@ -643,11 +643,16 @@ class Privacy(
/// The key a new top-level studyset is written with: a key of its own /// The key a new top-level studyset is written with: a key of its own
/// where this device can encrypt (new studysets are private, ADR 0017, /// where this device can encrypt (new studysets are private, ADR 0017,
/// D5), readable for an account without keys (D8). Throws /// D5), readable for an account without keys (D8); the latest key of the
/// shared space it was placed in (Halkyon's ADR 0044, D21). Throws
/// [PrivacyRefused] on a device without the keys of an account that has /// [PrivacyRefused] on a device without the keys of an account that has
/// them. /// them, or without a key of that space.
Future<String?> keyOfNewTopLevel(String studysetId) async { Future<String?> keyOfNewTopLevel(String studysetId) async {
await keys.refresh(); await keys.refresh();
if (spaces?.placedSpaceOf(studysetId) case final space?) {
_refuseKeyless();
return _usable(_spaceKey(space));
}
if (keys.canEncrypt) { if (keys.canEncrypt) {
return await keys.newStudysetKey(studysetId); return await keys.newStudysetKey(studysetId);
} }

View file

@ -38,6 +38,10 @@ class SpaceRouter(final SpaceLookup _lookup) {
/// parent, in the shared [space]: the top of a studyset shared (D14). /// parent, in the shared [space]: the top of a studyset shared (D14).
void placeIn(String studysetId, String space) => _placed[studysetId] = space; void placeIn(String studysetId, String space) => _placed[studysetId] = space;
/// The shared space [studysetId] was placed in and is about to be written
/// in; null for none.
String? placedSpaceOf(String studysetId) => _placed[studysetId];
/// Forgets which studysets are in which space, as a sync may have /// Forgets which studysets are in which space, as a sync may have
/// brought or taken some. /// brought or taken some.
void forget() => _spaces = null; void forget() => _spaces = null;

View file

@ -70,7 +70,10 @@ class ArchiveExporter(
/// The archive's files for the studyset [studysetId] and everything /// The archive's files for the studyset [studysetId] and everything
/// inside it but what waits in Recently deleted, `archive.json` first; /// inside it but what waits in Recently deleted, `archive.json` first;
/// [withProgress] as the class says. Throws [ArchiveFilesMissing] when a /// [withProgress] as the class says; [moved] for a studyset moved by
/// export and import, as sharing moves one (Halkyon's ADR 0044, D28),
/// keeping each item's link to Explore (`published`) and each card's
/// origin. Throws [ArchiveFilesMissing] when a
/// file the content names can't be had now, rather than leaving it out of /// file the content names can't be had now, rather than leaving it out of
/// an archive that would look whole. /// an archive that would look whole.
/// ///
@ -79,6 +82,7 @@ class ArchiveExporter(
Future<List<ExportFile>> export( Future<List<ExportFile>> export(
String studysetId, { String studysetId, {
required bool withProgress, required bool withProgress,
bool moved = false,
ExportProgress? onProgress, ExportProgress? onProgress,
AbortSignal? abort, AbortSignal? abort,
}) async { }) async {
@ -112,7 +116,7 @@ class ArchiveExporter(
Map<String, Object?> payloadOf(ByteData payload, String kind) => { Map<String, Object?> payloadOf(ByteData payload, String kind) => {
for (final MapEntry(:key, :value) in _decoded(payload).entries) for (final MapEntry(:key, :value) in _decoded(payload).entries)
if (key != 'published' && if ((moved || key != 'published') &&
(withProgress || !(_personal[kind]?.contains(key) ?? false))) (withProgress || !(_personal[kind]?.contains(key) ?? false)))
key: value, key: value,
}; };
@ -135,13 +139,21 @@ class ArchiveExporter(
if (studyset.isArchived) 'archived': true, if (studyset.isArchived) 'archived': true,
if (studyset.isStarred) 'starred': true, if (studyset.isStarred) 'starred': true,
}; };
for (final card in await _cards.cardsOf(studyset.id)) { final cards = await _cards.cardsOf(studyset.id);
// Moved, each card says where it came from, as its first revision.
final origins = moved
? await _cards.originsOf({for (final card in cards) card.id})
: const <String, String>{};
for (final card in cards) {
cardIds.add(card.id); cardIds.add(card.id);
items['${directory}cards/${card.id}.json'] = { items['${directory}cards/${card.id}.json'] = {
'id': card.id, 'id': card.id,
'position': card.position, 'position': card.position,
'tags': namesOf(tagging.of(card.id)), 'tags': namesOf(tagging.of(card.id)),
'payload': payloadOf(encodeCardPayload(card.content), 'card'), 'payload': payloadOf(
encodeCardPayload(card.content, origin: origins[card.id]),
'card',
),
}; };
} }
for (final note in await _notes.notesOf(studyset.id)) { for (final note in await _notes.notesOf(studyset.id)) {

View file

@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
@ -55,7 +56,8 @@ enum ArchiveImportStage() {
/// it (each is replayed from its log): the reviews, the answers to steps, /// it (each is replayed from its log): the reviews, the answers to steps,
/// the pages read; and which studysets are archived and starred. /// the pages read; and which studysets are archived and starred.
/// ///
/// Aborted, it deletes what it wrote (decided 9 Nov 2026): the top studyset /// Aborted, or failing (11 Nov 2026), it deletes what it wrote (decided 9
/// Nov 2026): the top studyset
/// for good with everything inside it, as Delete now does, and the files' /// for good with everything inside it, as Delete now does, and the files'
/// rows it made. Tags it made stay, as a tag is a name the person may well /// rows it made. Tags it made stay, as a tag is a name the person may well
/// have used anyway. /// have used anyway.
@ -82,10 +84,16 @@ class ArchiveImporter(
/// `ReleaseUnreadable` before writing anything when this app can't read /// `ReleaseUnreadable` before writing anything when this app can't read
/// all of it. Once [abort] is asked, stops between items, deletes what it /// all of it. Once [abort] is asked, stops between items, deletes what it
/// wrote and throws [WorkAborted]. /// wrote and throws [WorkAborted].
///
/// The top studyset gets the id [topId] where given; [moved], for a
/// studyset moved by export and import as sharing moves one (Halkyon's
/// ADR 0044, D28), keeps each card's own origin.
Future<ArchiveImported> import( Future<ArchiveImported> import(
ImportArchive archive, { ImportArchive archive, {
void Function(WorkProgress<ArchiveImportStage> progress)? onProgress, void Function(WorkProgress<ArchiveImportStage> progress)? onProgress,
AbortSignal? abort, AbortSignal? abort,
String? topId,
bool moved = false,
}) async { }) async {
final (:items, :missing) = await _withEntries(archive.items); final (:items, :missing) = await _withEntries(archive.items);
final trail = CopyTrail(); final trail = CopyTrail();
@ -97,9 +105,23 @@ class ArchiveImporter(
trail: trail, trail: trail,
onProgress: onProgress, onProgress: onProgress,
abort: abort, abort: abort,
topId: topId,
moved: moved,
); );
} on WorkAborted { } on Object {
await _discard(trail); // Aborted or failing, nothing of it stays half written; what can't be
// taken back is logged, and the first failure is what the import
// throws.
try {
await _discard(trail);
} on Object catch (error, st) {
log(
"Taking back an import's writes failed",
name: 'ArchiveImporter',
error: error,
stackTrace: st,
);
}
rethrow; rethrow;
} }
} }
@ -122,6 +144,8 @@ class ArchiveImporter(
required void Function(WorkProgress<ArchiveImportStage> progress)? required void Function(WorkProgress<ArchiveImportStage> progress)?
onProgress, onProgress,
required AbortSignal? abort, required AbortSignal? abort,
required String? topId,
required bool moved,
}) async { }) async {
final (:top, :ids) = await _copier.write( final (:top, :ids) = await _copier.write(
PublishedRelease(publicId: '', version: 0, items: items), PublishedRelease(publicId: '', version: 0, items: items),
@ -146,6 +170,8 @@ class ArchiveImporter(
), ),
abort: abort, abort: abort,
trail: trail, trail: trail,
topId: topId,
moved: moved,
); );
// The progress in four parts, each checked for abort after it. // The progress in four parts, each checked for abort after it.
var parts = 0; var parts = 0;

View file

@ -159,6 +159,14 @@ class CardRepository(final CardGateway _gateway) {
)).where((card) => card.id == id).firstOrNull; )).where((card) => card.id == id).firstOrNull;
} }
/// Where each of the cards [cardIds] came from, by id, for those its
/// first revision says were imported (e.g. "anki").
Future<Map<String, String>> originsOf(Set<String> cardIds) async => {
for (final revision in await _gateway.revisionsOf(cardIds))
if (revision.parents.isEmpty)
revision.cardId: ?cardOriginOf(revision.payload),
};
/// Every card of the person on this device: its id, its studyset, when it /// Every card of the person on this device: its id, its studyset, when it
/// was made, and whether it was imported (its first revision says where /// was made, and whether it was imported (its first revision says where
/// from). Kept until a card changes: Home asks after every rating, and /// from). Kept until a card changes: Home asks after every rating, and
@ -211,11 +219,13 @@ class CardRepository(final CardGateway _gateway) {
} }
/// Creates cards with [contents] at the end of [studysetId], in order, at /// Creates cards with [contents] at the end of [studysetId], in order, at
/// once; returns their ids in the same order. [origin] as for [create]. /// once; returns their ids in the same order. [origin] as for [create];
/// [origins], where given, each card's own in its place.
Future<List<String>> createAll( Future<List<String>> createAll(
String studysetId, String studysetId,
List<CardContent> contents, { List<CardContent> contents, {
String? origin, String? origin,
List<String?>? origins,
}) async { }) async {
if (contents.isEmpty) { if (contents.isEmpty) {
return const []; return const [];
@ -229,7 +239,10 @@ class CardRepository(final CardGateway _gateway) {
for (final (i, content) in contents.indexed) for (final (i, content) in contents.indexed)
( (
position: positions[i], position: positions[i],
payload: encodeCardPayload(content, origin: origin), payload: encodeCardPayload(
content,
origin: origins == null ? origin : origins[i],
),
), ),
]); ]);
_changed(); _changed();

View file

@ -112,7 +112,10 @@ class ReleaseCopier(
/// ///
/// Tells [onStage] each stage's count as it goes, and stops between /// Tells [onStage] each stage's count as it goes, and stops between
/// items with [WorkAborted] once [abort] is asked, [trail] holding what /// items with [WorkAborted] once [abort] is asked, [trail] holding what
/// it wrote by then. /// it wrote by then. The top studyset gets the id [topId] where given;
/// [moved], for a studyset moved by export and import (Halkyon's ADR
/// 0044, D28), keeps each card's own origin, none for a card written
/// by the person, in place of [cardOrigin].
Future<({String top, Map<String, String> ids})> write( Future<({String top, Map<String, String> ids})> write(
PublishedRelease release, { PublishedRelease release, {
required Future<Uint8List> Function(String sha256) fileOf, required Future<Uint8List> Function(String sha256) fileOf,
@ -121,6 +124,8 @@ class ReleaseCopier(
void Function(CopyStage stage, int done, int total)? onStage, void Function(CopyStage stage, int done, int total)? onStage,
AbortSignal? abort, AbortSignal? abort,
CopyTrail? trail, CopyTrail? trail,
String? topId,
bool moved = false,
}) => _copy( }) => _copy(
release, release,
fileOf: fileOf, fileOf: fileOf,
@ -131,6 +136,8 @@ class ReleaseCopier(
origin: null, origin: null,
cardOrigin: cardOrigin, cardOrigin: cardOrigin,
tags: tags, tags: tags,
topId: topId,
moved: moved,
); );
Future<({String top, Map<String, String> ids})> _copy( Future<({String top, Map<String, String> ids})> _copy(
@ -144,6 +151,8 @@ class ReleaseCopier(
String? location, String? location,
String? cardOrigin, String? cardOrigin,
Map<String, TagLook> tags = const {}, Map<String, TagLook> tags = const {},
String? topId,
bool moved = false,
}) async { }) async {
// Read all of it first: a copy is whole or not made. // Read all of it first: a copy is whole or not made.
final unnamed = _read(release, const {}); final unnamed = _read(release, const {});
@ -195,6 +204,7 @@ class ReleaseCopier(
: _withOrigin(content, origin(rootItem.id), location: location); : _withOrigin(content, origin(rootItem.id), location: location);
final root = studysetIds[''] = ids[rootItem.id] = await _studysets.create( final root = studysetIds[''] = ids[rootItem.id] = await _studysets.create(
rootContent(rootItem.content), rootContent(rootItem.content),
id: topId,
); );
trail?.top = root; trail?.top = root;
contentDone++; contentDone++;
@ -313,13 +323,19 @@ class ReleaseCopier(
for (final directory in named.directories) { for (final directory in named.directories) {
final studysetId = studysetIds[directory]!; final studysetId = studysetIds[directory]!;
final cards = _ordered(named.cards[directory]); final cards = _ordered(named.cards[directory]);
final cardIds = await _cards.createAll(studysetId, [ final cardIds = await _cards.createAll(
for (final card in cards) studysetId,
if (origin == null) [
card.content for (final card in cards)
else if (origin == null)
card.content.withField(CardField.published, origin(card.id)), card.content
], origin: cardOrigin); else
card.content.withField(CardField.published, origin(card.id)),
],
origin: cardOrigin,
// Moved, each card keeps where it came from.
origins: moved ? [for (final card in cards) card.origin] : null,
);
for (final (i, card) in cards.indexed) { for (final (i, card) in cards.indexed) {
ids[card.id] = cardIds[i]; ids[card.id] = cardIds[i];
} }
@ -447,6 +463,7 @@ class ReleaseCopier(
position: position, position: position,
tags: tags, tags: tags,
content: decodeCardPayload(payload), content: decodeCardPayload(payload),
origin: cardOriginOf(payload),
), ),
); );
case 'notes': case 'notes':
@ -654,11 +671,12 @@ final class _Read() {
} }
/// An item of a release: its id there, its position, its tags by name, a /// An item of a release: its id there, its position, its tags by name, a
/// step's kind, and its content. /// step's kind, its content, and where a card came from (an archive's).
final class _Item<T>({ final class _Item<T>({
required final String id, required final String id,
required final String position, required final String position,
required final T content, required final T content,
final List<String> tags = const [], final List<String> tags = const [],
final StepKind? kind, final StepKind? kind,
final String? origin,
}); });

View file

@ -0,0 +1,27 @@
import 'package:halkyon_base_client/halkyon_base_client.dart' as base;
/// The shared spaces the instance keeps (halkyon_base's `sharedSpace`
/// endpoint, Halkyon's ADR 0044, D1).
abstract interface class SharedSpaceGateway() {
/// Makes a shared space holding a studyset, with the person its Owner;
/// returns its id, the sync engine's space.
Future<String> create();
/// Deletes [space] with everything in it; its Owner's.
Future<void> delete(String space);
}
/// [SharedSpaceGateway] on halkyon_base's `sharedSpace` endpoint.
class ServerpodSharedSpaceGateway(final base.Caller _base)
implements SharedSpaceGateway {
/// What a Learn space holds, in the module's words for it.
static const kind = 'studyset';
@override
Future<String> create() async =>
(await _base.sharedSpace.create(kind)).id.uuid;
@override
Future<void> delete(String space) =>
_base.sharedSpace.delete(base.UuidValue.withValidation(space));
}

View file

@ -0,0 +1,176 @@
import 'dart:developer';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_exporter.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart'
show ExportFile;
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/data/shared_space_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
/// Why a studyset isn't shared.
enum SharingRefusal() {
/// It, or a studyset inside it, has a publication on Explore, withdrawn
/// or not (Halkyon's ADR 0044, D27): a shared studyset could take no
/// release.
published,
/// This device doesn't hold the account's keys, or the account has none:
/// a shared studyset is always sealed (D21), so someone without a
/// password sets one first.
keysMissing,
/// The new space didn't reach this device: no connection, or the
/// instance didn't answer in time. Nothing was written into it.
notReached,
}
/// A studyset not shared, for [reason].
final class const SharingRefused(final SharingRefusal reason)
implements Exception {
@override
String toString() => 'SharingRefused(${reason.name})';
}
/// The stages of sharing a studyset: the space made and readied, the
/// studyset read out, then written into the space (as an import's files,
/// content and progress), and the old one deleted.
enum SharingStage() {
space,
reading,
files,
content,
progress,
cleaning,
}
/// What sharing needs of the device beyond the person's repositories: the
/// new space reached, its first key, and the top studyset placed in it.
abstract interface class SpaceSetup() {
/// Whether this device can seal (it holds the account's keys).
bool get canSeal;
/// Waits until [space], just made, reached this device, the person a
/// member who writes there, then makes the space's first key and seals
/// it to them. Throws [SharingRefused] (`notReached`) where it doesn't
/// come.
Future<void> ready(String space);
/// Places the studyset [studysetId], not written yet, in [space].
void placeIn(String studysetId, String space);
}
/// Shares a studyset (Halkyon's ADR 0044, D14, D23, D27, D28): a shared
/// space is made with the person its Owner, and the studyset with
/// everything inside it is moved there by the archive's export and import,
/// so it takes new IDs, its reviews, answers, readings, tags, stars and the
/// archive come along, each item keeps its link to Explore and each card
/// its origin; once the copy is whole, the old studyset is deleted for
/// good. Shared, it is at the top level (D23).
///
/// Refused before anything is made for a studyset with a publication in
/// its tree (D27) or on a device that can't seal (D21). Failing or aborted
/// after the space was made, what was written is taken back (the import's
/// own doing) and the space deleted; the studyset stays as it was.
class StudysetSharing(
final SharedSpaceGateway _spaces,
final SpaceSetup _setup,
final StudysetRepository _studysets,
final PublishGateway _publications,
final ArchiveExporter _exporter,
/// The exported files read back as an archive (packed, then read as an
/// import reads a file).
final Future<ImportArchive> Function(List<ExportFile> files) _pack,
final ArchiveImporter _importer,
final StudysetDeletions _deletions,
) {
static final _stages = WorkStages(
SharingStage.values,
weights: const {SharingStage.reading: 2, SharingStage.content: 2},
);
/// Shares the studyset [studysetId]; returns the shared studyset's id.
/// Tells [onProgress] how far it has come; once [abort] is asked, stops
/// between items, takes back what it made and throws [WorkAborted].
/// Throws [SharingRefused] where it isn't shared.
Future<String> share(
String studysetId, {
void Function(WorkProgress<SharingStage> progress)? onProgress,
AbortSignal? abort,
}) async {
if (!_setup.canSeal) {
throw const SharingRefused(SharingRefusal.keysMissing);
}
onProgress?.call(_stages.at(SharingStage.space));
final tree = subtreeOf(studysetId, await _studysets.withDeleted());
for (final id in tree) {
if (await _publications.publication(id) != null) {
throw const SharingRefused(SharingRefusal.published);
}
}
abort?.check();
final space = await _spaces.create();
try {
await _setup.ready(space);
onProgress?.call(_stages.at(SharingStage.space, done: 1, total: 1));
abort?.check();
final files = await _exporter.export(
studysetId,
withProgress: true,
moved: true,
onProgress: (_, done, total) => onProgress?.call(
_stages.at(SharingStage.reading, done: done, total: total),
),
abort: abort,
);
final archive = await _pack(files);
abort?.check();
final top = Privacy.newId().uuid;
_setup.placeIn(top, space);
await _importer.import(
archive,
topId: top,
moved: true,
abort: abort,
onProgress: (progress) => onProgress?.call(
_stages.at(
switch (progress.stage) {
ArchiveImportStage.files => SharingStage.files,
ArchiveImportStage.content => SharingStage.content,
ArchiveImportStage.progress => SharingStage.progress,
},
done: progress.done,
total: progress.total,
),
),
);
// Whole now: the old one goes, and is never taken back.
onProgress?.call(_stages.at(SharingStage.cleaning));
await _deletions.deleteForGood(studysetId);
onProgress?.call(_stages.at(SharingStage.cleaning, done: 1, total: 1));
return top;
} on Object {
try {
await _spaces.delete(space);
} on Object catch (error, st) {
log(
'Deleting the space of a share that failed failed',
name: 'StudysetSharing',
error: error,
stackTrace: st,
);
}
rethrow;
}
}
}

View file

@ -0,0 +1,52 @@
import 'dart:async';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/shared_space_keys.dart';
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
/// [SpaceSetup] on the device's sync: a new space reaches the device by a
/// sync, which brings the person's membership of it (the engine's grant,
/// Halkyon's ADR 0044, D15); then its first key is made and sealed to them
/// (D3), and the top studyset is placed through the router (D24).
class SyncSpaceSetup(
final SyncService _sync,
final SpaceLookup _lookup,
final SpaceRouter _router,
final SharedSpaceKeys _keys,
/// Whether this device holds the account's keys.
final bool Function() _canSeal, {
/// How long each sync is waited for.
final Duration wait = const Duration(seconds: 30),
/// How many syncs are tried before the space counts as not reached.
final int tries = 3,
}) implements SpaceSetup {
@override
bool get canSeal => _canSeal();
@override
Future<void> ready(String space) async {
for (var i = 0; i < tries; i++) {
final synced = _sync.synced.first;
_sync.syncNow();
try {
await synced.timeout(wait);
} on TimeoutException {
continue;
}
_router.forget();
if ((await _lookup.sharedStudysets()).containsKey(space)) {
await _keys.newKey(space);
return;
}
}
throw const SharingRefused(SharingRefusal.notReached);
}
@override
void placeIn(String studysetId, String space) =>
_router.placeIn(studysetId, space);
}

View file

@ -35,14 +35,16 @@ abstract interface class StudysetGateway() {
/// Stores a new studyset, made at [updatedAt], inside [parentId] (or at /// Stores a new studyset, made at [updatedAt], inside [parentId] (or at
/// the top level) at [position] among its siblings, and returns its id, a /// the top level) at [position] among its siblings, and returns its id, a
/// UUID v7 made on the device. Inside a private tree it is private too; at /// UUID v7 made on the device, or [id] where given (a top studyset placed
/// the top level it is private where this device holds the account's /// in a shared space before it is written, Halkyon's ADR 0044, D24).
/// keys (ADR 0019). /// Inside a private tree it is private too; at the top level it is
/// private where this device holds the account's keys (ADR 0019).
Future<String> insert( Future<String> insert(
ByteData payload, ByteData payload,
DateTime updatedAt, { DateTime updatedAt, {
String? parentId, String? parentId,
String position = 'a0', String position = 'a0',
String? id,
}); });
/// Makes the top-level studyset [id] private, with everything inside it, /// Makes the top-level studyset [id] private, with everything inside it,

View file

@ -98,16 +98,22 @@ class StudysetRepository(final StudysetGateway _gateway) {
]; ];
/// Creates a studyset with [content] inside [parentId], after the /// Creates a studyset with [content] inside [parentId], after the
/// studysets already there, or at the top level; returns its id. /// studysets already there, or at the top level; returns its id, [id]
Future<String> create(StudysetContent content, {String? parentId}) async { /// where given (see [StudysetGateway.insert]).
final id = await _gateway.insert( Future<String> create(
StudysetContent content, {
String? parentId,
String? id,
}) async {
final made = await _gateway.insert(
encodeStudysetPayload(content), encodeStudysetPayload(content),
_now(), _now(),
parentId: parentId, parentId: parentId,
position: _after(await _siblings(parentId)), position: _after(await _siblings(parentId)),
id: id,
); );
await _refresh(); await _refresh();
return id; return made;
} }
/// Moves the studyset [id] into [parentId], after the studysets already /// Moves the studyset [id] into [parentId], after the studysets already

View file

@ -71,27 +71,28 @@ class SyncStudysetGateway(
DateTime updatedAt, { DateTime updatedAt, {
String? parentId, String? parentId,
String position = 'a0', String position = 'a0',
String? id,
}) async { }) async {
final id = Privacy.newId(); final uuid = id == null ? Privacy.newId() : _uuid(id)!;
// Inside a tree, its key; at the top level, a key of its own where this // Inside a tree, its key; at the top level, a key of its own where this
// device can encrypt: new studysets are private (ADR 0017, D5). A // device can encrypt: new studysets are private (ADR 0017, D5). A
// device without the keys of an account that has them makes none // device without the keys of an account that has them makes none
// (PrivacyRefused). // (PrivacyRefused).
final keyId = parentId != null final keyId = parentId != null
? await _privacy.keyToWriteIn(parentId) ? await _privacy.keyToWriteIn(parentId)
: await _privacy.keyOfNewTopLevel(id.uuid); : await _privacy.keyOfNewTopLevel(uuid.uuid);
final sealed = _privacy.seal( final sealed = _privacy.seal(
payload, payload,
keyId: keyId, keyId: keyId,
table: 'studyset', table: 'studyset',
bound: '', bound: '',
id: id.uuid, id: uuid.uuid,
item: 'studyset:${id.uuid}', item: 'studyset:${uuid.uuid}',
); );
await protocol.Studyset.db.insertRow( await protocol.Studyset.db.insertRow(
_db, _db,
protocol.Studyset( protocol.Studyset(
id: id, id: uuid,
keyId: sealed.keyId, keyId: sealed.keyId,
nonce: sealed.nonce, nonce: sealed.nonce,
payload: sealed.payload, payload: sealed.payload,
@ -102,7 +103,7 @@ class SyncStudysetGateway(
); );
_privacy.forgetTrees(); _privacy.forgetTrees();
_sync.requestSync(); _sync.requestSync();
return id.uuid; return uuid.uuid;
} }
@override @override

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart'
import 'package:halkyon_learn_flutter/src/core/privacy/data/shared_space_keys.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/data/shared_space_keys.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/sync/space_router.dart'; import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_space_lookup.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/studyset/data/sync_studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart'; import 'package:serverpod_offline_sync_client/offline_sync.dart';
@ -22,7 +23,8 @@ import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// claims is shared keeps its own key. On the real gateways, over a local /// claims is shared keeps its own key. On the real gateways, over a local
/// database without a server. /// database without a server.
void main() { void main() {
const space = 'space-1'; final person = const protocol.Uuid().v7obj();
final space = const protocol.Uuid().v7();
final crypto = FakeAccountCrypto(); final crypto = FakeAccountCrypto();
final alice = crypto.keysNumbered(1); final alice = crypto.keysNumbered(1);
late OfflineSyncDatabaseSession db; late OfflineSyncDatabaseSession db;
@ -37,7 +39,7 @@ void main() {
final client = protocol.Client('http://127.0.0.1:9/'); final client = protocol.Client('http://127.0.0.1:9/');
db = await client.createSyncSession( db = await client.createSyncSession(
'${folder.path}/sync.sqlite', '${folder.path}/sync.sqlite',
persistentUserId: const protocol.Uuid().v7obj(), persistentUserId: person,
); );
sync = SyncService( sync = SyncService(
({required onMerge}) async {}, ({required onMerge}) async {},
@ -63,9 +65,10 @@ void main() {
SharedSpaceKeys spaceKeys, SharedSpaceKeys spaceKeys,
SyncStudysetGateway studysets, SyncStudysetGateway studysets,
SyncCardGateway cards, SyncCardGateway cards,
SpaceRouter router,
}) })
> >
device() async { device({bool onTheEngine = false}) async {
final ledger = PrivacyLedger(MemoryPrivacyLedgerStore()); final ledger = PrivacyLedger(MemoryPrivacyLedgerStore());
await ledger.open(); await ledger.open();
final keys = ContentKeys( final keys = ContentKeys(
@ -85,8 +88,19 @@ void main() {
keys.cipher, keys.cipher,
() async => {space}, () async => {space},
); );
// On the engine, the router asks the database, and the sync engine
// places each new row as the app's does.
final router = SpaceRouter(
onTheEngine ? SyncSpaceLookup(db, person.uuid) : lookup,
);
if (onTheEngine) {
db.db.newRowSpace = (row) async => switch (await router.spaceOf(row)) {
final space? => protocol.UuidValue.withValidation(space),
null => null,
};
}
final privacy = Privacy(db, keys) final privacy = Privacy(db, keys)
..spaces = SpaceRouter(lookup) ..spaces = router
..spaceKeys = spaceKeys; ..spaceKeys = spaceKeys;
addTearDown(() async { addTearDown(() async {
await privacy.dispose(); await privacy.dispose();
@ -98,6 +112,7 @@ void main() {
spaceKeys: spaceKeys, spaceKeys: spaceKeys,
studysets: SyncStudysetGateway(db, sync, privacy), studysets: SyncStudysetGateway(db, sync, privacy),
cards: SyncCardGateway(db, sync, const protocol.Uuid().v7(), privacy), cards: SyncCardGateway(db, sync, const protocol.Uuid().v7(), privacy),
router: router,
); );
} }
@ -174,6 +189,83 @@ void main() {
expect(await revisionKeys(card), [first.value, second.value]); expect(await revisionKeys(card), [first.value, second.value]);
}); });
test('a studyset placed in a space before it is written goes there with '
"the space's key, and so do the studysets and cards inside it", () async {
await _joined(db, person, space);
final laptop = await device(onTheEngine: true);
final top = Privacy.newId().uuid;
laptop.router.placeIn(top, space);
await expectLater(
laptop.studysets.insert(
json({'schemaVersion': 1, 'title': 'Shared'}),
DateTime.utc(2026, 11, 11),
id: top,
),
throwsA(
isA<PrivacyRefused>().having(
(r) => r.reason,
'reason',
PrivacyRefusal.spaceKeyMissing,
),
),
reason: 'no key of the space is open yet',
);
expect(
await protocol.Studyset.db.findById(
db,
protocol.UuidValue.withValidation(top),
),
isNull,
);
final key = await laptop.spaceKeys.newKey(space);
expect(
await laptop.studysets.insert(
json({'schemaVersion': 1, 'title': 'Shared'}),
DateTime.utc(2026, 11, 11),
id: top,
),
top,
);
final child = await laptop.studysets.insert(
json({'schemaVersion': 1, 'title': 'Inside'}),
DateTime.utc(2026, 11, 11),
parentId: top,
);
final card = await laptop.cards.insertCard(
studysetId: child,
position: 'a0',
payload: cardPayload('shared'),
);
final spaceUuid = protocol.UuidValue.withValidation(space);
for (final id in [top, child]) {
final row = (await protocol.Studyset.db.findById(
db,
protocol.UuidValue.withValidation(id),
))!;
expect(row.keyId, key.value);
expect(
await protocol.Studyset.db.count(
db,
where: (t) => t.id.equals(row.id) & t.spaceEquals(spaceUuid),
),
1,
);
}
expect(await revisionKeys(card), [key.value]);
expect(
await protocol.Card.db.count(
db,
where: (t) =>
t.id.equals(protocol.UuidValue.withValidation(card)) &
t.spaceEquals(spaceUuid),
),
1,
);
});
test('a shared studyset is never made readable, nor given a key of the ' test('a shared studyset is never made readable, nor given a key of the '
"person's own (D21)", () async { "person's own (D21)", () async {
final laptop = await device(); final laptop = await device();
@ -215,6 +307,37 @@ void main() {
}); });
} }
/// Makes the person a member who writes of [space] in [db], as a sync
/// brings a space: the engine knows a space by a row in it, here a studyset
/// of a member's.
Future<void> _joined(
OfflineSyncDatabaseSession db,
protocol.UuidValue person,
String space,
) async {
final uuid = protocol.UuidValue.withValidation(space);
await db.db.transactionForUser(
uuid,
(t) => protocol.Studyset.db.insertRow(
db,
protocol.Studyset(payload: ByteData(1)),
transaction: t,
),
);
final row = await OfflineSyncSpace.db.findFirstRow(
db,
where: (t) => t.uuidSpaceId.equals(uuid),
);
await OfflineSyncSpaceMember.db.insertRow(
db,
OfflineSyncSpaceMember(
spaceId: row!.id!,
userUuid: person,
role: OfflineSyncSpaceRole.readWrite,
),
);
}
/// The studysets in each shared space, as the sync engine's grants say. /// The studysets in each shared space, as the sync engine's grants say.
class _Lookup() implements SpaceLookup { class _Lookup() implements SpaceLookup {
final shared = <String, Set<String>>{}; final shared = <String, Set<String>>{};

View file

@ -264,6 +264,7 @@ void main() {
late PdfSourceRepository pdfs; late PdfSourceRepository pdfs;
late AssetRepository assets; late AssetRepository assets;
late FakeAssetGateway assetRows; late FakeAssetGateway assetRows;
late FakeReviewGateway reviewRows;
late ArchiveImporter importer; late ArchiveImporter importer;
setUp(() { setUp(() {
@ -285,7 +286,8 @@ void main() {
FakeAssetSealing(), FakeAssetSealing(),
); );
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets); pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
reviews = ReviewRepository(FakeReviewGateway()); reviewRows = FakeReviewGateway();
reviews = ReviewRepository(reviewRows);
importer = ArchiveImporter( importer = ArchiveImporter(
ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets), ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets),
DictionaryRepository( DictionaryRepository(
@ -413,13 +415,35 @@ void main() {
throwsA(isA<WorkAborted>()), throwsA(isA<WorkAborted>()),
); );
// The reviews written go with their cards: the database // The reviews written go with their cards: the card gateway
// cascades (`review_log.card`), which the fakes don't. // deletes them with the card (ADR 0044, D26), which the fakes
// don't.
expect(await studysets.withDeleted(), isEmpty); expect(await studysets.withDeleted(), isEmpty);
expect(await cards.allCards(), isEmpty); expect(await cards.allCards(), isEmpty);
}, },
); );
test('failing midway, deletes everything it wrote, as aborted '
'(11 Nov 2026)', () async {
final own = await studysets.create(StudysetContent.titled('Own'));
final ownFile = await assets.add(
kept,
fileName: 'mine.png',
mimeType: 'image/png',
studysetId: own,
);
reviewRows.failWrites = Exception('the disk is full');
await expectLater(
importer.import(readArchive(zip(withFiles()))),
throwsA(isA<Exception>()),
);
expect([for (final s in await studysets.withDeleted()) s.id], [own]);
expect(await cards.allCards(), isEmpty);
expect([for (final row in await assetRows.all()) row.id], [ownFile]);
});
test('asked once all of it is written, it is done', () async { test('asked once all of it is written, it is done', () async {
final abort = AbortSignal(); final abort = AbortSignal();

View file

@ -0,0 +1,346 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart'
show FakeFileServerGateway, MemoryFileCache;
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_exporter.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/data/shared_space_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_sealing.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_device_dictionaries.dart';
import '../../support/fake_dictionary_gateway.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_path_answer_gateway.dart';
import '../../support/fake_pdf_source_gateway.dart';
import '../../support/fake_publish_gateway.dart';
import '../../support/fake_review_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/fake_tag_gateway.dart';
/// Sharing a studyset (Halkyon's ADR 0044, D14, D27, D28): moved into a
/// new space by the archive's export and import, the old one deleted once
/// the copy is whole; refused before anything is made where it can't be;
/// taken back, the space with it, where it fails.
void main() {
late StudysetRepository studysets;
late CardRepository cards;
late ReviewRepository reviews;
late FakeReviewGateway reviewRows;
late _Spaces spaces;
late _Setup setup;
late Map<String, Publication> publications;
late StudysetSharing sharing;
setUp(() {
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway());
final notes = NoteRepository(FakeNoteGateway());
final paths = LearningPathRepository(FakeLearningPathGateway());
final progress = PathProgressRepository(FakePathAnswerGateway());
reviewRows = FakeReviewGateway();
reviews = ReviewRepository(reviewRows);
final assets = AssetRepository(
FakeAssetGateway(),
FakeFileServerGateway(),
MemoryFileCache(),
FakeAssetSealing(),
);
final tags = TagRepository(
FakeTagGateway(),
cardChanges: cards.changes,
noteChanges: notes.changes,
pathChanges: paths.changes,
);
final pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
final deletions = StudysetDeletions(studysets, cards, notes, paths, pdfs);
spaces = _Spaces();
setup = _Setup(studysets);
publications = {};
sharing = StudysetSharing(
spaces,
setup,
studysets,
FakePublishGateway(publications: publications),
ArchiveExporter(
studysets,
cards,
notes,
paths,
progress,
pdfs,
tags,
reviews,
assets.fileOf,
),
(files) async => readArchive(zipExport(files)),
ArchiveImporter(
ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets),
DictionaryRepository(FakeDictionaryGateway(), noDeviceDictionaries()),
reviews,
progress,
studysets,
pdfs,
deletions,
assets,
),
deletions,
);
});
CardContent card(String front) =>
CardContent(front: RichContent.plain(front), back: RichContent.plain(''));
const fromExplore = PublishedRef(
publication: 'genki',
item: 'card-in-release',
version: 3,
);
/// Japanese with Vocabulary inside: a card written here, one from Anki,
/// one copied from Explore, and a review of the first.
Future<({String top, String inside, String own})> japanese() async {
final top = await studysets.create(StudysetContent.titled('Japanese'));
final inside = await studysets.create(
StudysetContent.titled('Vocabulary'),
parentId: top,
);
final own = await cards.create(inside, card('mine'));
await cards.create(inside, card('from Anki'), origin: 'anki');
await cards.create(
inside,
card('from Explore').withField(CardField.published, fromExplore),
);
await reviews.record(
cardId: own,
rating: Rating.good,
reviewedAt: DateTime.utc(2026, 11, 10),
duration: const Duration(seconds: 4),
);
return (top: top, inside: inside, own: own);
}
test('moves the studyset with everything inside it into a new space, '
'under new IDs, the old one gone', () async {
final before = await japanese();
final shared = await sharing.share(before.top);
expect(spaces.made, ['space-1']);
expect(spaces.deleted, isEmpty);
expect(setup.readied, ['space-1']);
// Placed before it was written, so its rows go in the space.
expect(setup.placed, [(shared, 'space-1', existedThen: false)]);
final all = await studysets.withDeleted();
expect([for (final s in all) s.content.title]..sort(), [
'Japanese',
'Vocabulary',
]);
expect(all.map((s) => s.id), isNot(contains(before.top)));
expect(all.map((s) => s.id), isNot(contains(before.inside)));
final top = all.singleWhere((s) => s.id == shared);
expect(top.parentId, isNull);
expect(top.content.title, 'Japanese');
final inside = all.singleWhere((s) => s.parentId == shared);
final moved = await cards.cardsOf(inside.id);
expect(moved.map((c) => c.id), isNot(contains(before.own)));
expect(
{for (final c in moved) c.content.front.plainText: c.content.published},
{'mine': null, 'from Anki': null, 'from Explore': fromExplore},
reason: 'each item keeps its link to Explore',
);
final stamps = {
for (final stamp in await cards.allCards()) stamp.id: stamp.imported,
};
expect(
{
for (final c in moved) c.content.front.plainText: stamps[c.id],
},
{'mine': false, 'from Anki': true, 'from Explore': false},
reason: 'each card keeps where it came from',
);
final mine = moved.singleWhere(
(c) => c.content.front.plainText == 'mine',
);
// The old card's reviews go with it in the card gateway (ADR 0044,
// D26), which the fakes don't do.
expect((await reviews.reviewsOf({mine.id})).length, 1);
});
test('refuses a studyset with a publication anywhere in it, withdrawn too, '
'before anything is made (D27)', () async {
final before = await japanese();
publications[before.inside] = _publication();
await expectLater(
sharing.share(before.top),
throwsA(
isA<SharingRefused>().having(
(r) => r.reason,
'reason',
SharingRefusal.published,
),
),
);
expect(spaces.made, isEmpty);
expect((await studysets.withDeleted()).length, 2);
});
test("refuses on a device that can't seal, before anything is made "
'(D21)', () async {
final before = await japanese();
setup.canSeal = false;
await expectLater(
sharing.share(before.top),
throwsA(
isA<SharingRefused>().having(
(r) => r.reason,
'reason',
SharingRefusal.keysMissing,
),
),
);
expect(spaces.made, isEmpty);
});
test("a space that doesn't reach the device is deleted, and nothing is "
'written', () async {
final before = await japanese();
setup.reaches = false;
await expectLater(
sharing.share(before.top),
throwsA(isA<SharingRefused>()),
);
expect(spaces.deleted, ['space-1']);
expect(
[for (final s in await studysets.withDeleted()) s.id],
[
before.top,
before.inside,
],
);
});
test('failing midway, or aborted, takes back what it wrote and deletes the '
'space; the studyset stays as it was', () async {
final before = await japanese();
reviewRows.failWrites = Exception('the disk is full');
await expectLater(sharing.share(before.top), throwsA(isA<Exception>()));
expect(spaces.deleted, ['space-1']);
expect(
[for (final s in await studysets.withDeleted()) s.id],
[
before.top,
before.inside,
],
);
expect((await cards.cardsOf(before.inside)).length, 3);
reviewRows.failWrites = null;
final abort = AbortSignal();
await expectLater(
sharing.share(
before.top,
abort: abort,
onProgress: (progress) {
if (progress.stage == SharingStage.content) {
abort.abort();
}
},
),
throwsA(isA<WorkAborted>()),
);
expect(spaces.deleted, ['space-1', 'space-2']);
expect(
[for (final s in await studysets.withDeleted()) s.id],
[
before.top,
before.inside,
],
);
});
}
Publication _publication() => const Publication(
publicId: 'public-1',
slug: 'vocabulary',
location: 'halkyon.eu/@ana/vocabulary',
withdrawn: true,
);
/// The instance's shared spaces: those made and deleted, in order.
class _Spaces() implements SharedSpaceGateway {
final made = <String>[];
final deleted = <String>[];
@override
Future<String> create() async {
final space = 'space-${made.length + 1}';
made.add(space);
return space;
}
@override
Future<void> delete(String space) async => deleted.add(space);
}
/// The device's side of a new space: the spaces readied and the studysets
/// placed, with whether each existed when placed.
class _Setup(final StudysetRepository _studysets) implements SpaceSetup {
@override
bool canSeal = true;
/// Whether a new space reaches the device.
bool reaches = true;
final readied = <String>[];
final placed = <(String, String, {bool existedThen})>[];
@override
Future<void> ready(String space) async {
if (!reaches) {
throw const SharingRefused(SharingRefusal.notReached);
}
readied.add(space);
}
@override
void placeIn(String studysetId, String space) {
// Asked synchronously; whether it exists is looked up at once.
placed.add((studysetId, space, existedThen: false));
_studysets.withDeleted().then((all) {
if (all.any((s) => s.id == studysetId)) {
placed[placed.length - 1] = (studysetId, space, existedThen: true);
}
});
}
}

View file

@ -0,0 +1,124 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/shared_space_keys.dart';
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
import 'package:halkyon_learn_flutter/src/feature/sharing/data/sync_space_setup.dart';
/// A new space readied on the device (Halkyon's ADR 0044, D3, D15): synced
/// until the person's membership of it came, then its first key made and
/// sealed to them; refused where it doesn't come.
void main() {
const space = 'space-1';
final account = FakeAccountCrypto().keysNumbered(1);
late FakeSharedKeysServer server;
late _Lookup lookup;
late int syncs;
late SyncService sync;
late SharedSpaceKeys keys;
late SpaceRouter router;
/// The membership comes with the sync numbered [comesWith] (from 1);
/// never where null.
Future<SyncSpaceSetup> setup({required int? comesWith}) async {
syncs = 0;
sync = SyncService(({required onMerge}) async {
if (++syncs == comesWith) {
lookup.shared[space] = {};
}
}, debounce: const Duration(days: 1));
addTearDown(sync.dispose);
final cipher = FakeContentCipher();
await cipher.unlock(account);
keys = SharedSpaceKeys(
SharedKeys(
server.as('alice'),
KeyPins(MemoryKeyPinStore()),
cipher,
me: 'alice',
),
cipher,
() async => lookup.shared.keys.toSet(),
);
router = SpaceRouter(lookup);
return SyncSpaceSetup(
sync,
lookup,
router,
keys,
() => true,
wait: const Duration(seconds: 1),
);
}
setUp(() {
server = FakeSharedKeysServer()
..addMember(
space,
'alice',
keys: (
encryption: account.encryptionPublicKey,
signing: account.signingPublicKey,
),
);
lookup = _Lookup();
});
test('waits for the sync that brings the space, then makes its first '
'key', () async {
final readied = await setup(comesWith: 2);
await readied.ready(space);
expect(syncs, 2);
expect(keys.latestOf(space)?.space, space);
expect(server.sealedCount(space), 1);
});
test("a space that doesn't come is refused, with no key made", () async {
final readied = await setup(comesWith: null);
await expectLater(
readied.ready(space),
throwsA(
isA<SharingRefused>().having(
(r) => r.reason,
'reason',
SharingRefusal.notReached,
),
),
);
expect(syncs, 3);
expect(keys.latestOf(space), isNull);
expect(server.sealedCount(space), 0);
});
test('places a studyset through the router', () async {
final readied = await setup(comesWith: 1);
readied.placeIn('top', space);
expect(router.placedSpaceOf('top'), space);
});
}
/// The studysets in each shared space the person is a member of.
class _Lookup() implements SpaceLookup {
final shared = <String, Set<String>>{};
@override
Future<Map<String, Set<String>>> sharedStudysets() async => shared;
@override
Future<String?> studysetOfCard(String id) async => null;
@override
Future<String?> studysetOfNote(String id) async => null;
@override
Future<String?> studysetOfPath(String id) async => null;
@override
Future<String?> pathOfStep(String id) async => null;
}

View file

@ -156,8 +156,9 @@ class FakeStudysetGateway() implements StudysetGateway {
DateTime updatedAt, { DateTime updatedAt, {
String? parentId, String? parentId,
String position = 'a0', String position = 'a0',
String? id,
}) async { }) async {
final id = 'studyset-${_nextId++}'; id ??= 'studyset-${_nextId++}';
_rows[id] = ( _rows[id] = (
id: id, id: id,
payload: payload, payload: payload,