feat(todo): the person's projects, sealed and synced, the Inbox made once (ADR 0033, D5, D6)
TodoPerson opens the person's own sync database in their folder, halkyon_app's SyncService behind AppCompatibility, their key (TodoKeys, Train's copied with Todo's own key IDs) and their projects, and closes them in that order, the key last. A project is sealed for its row; a readable, foreign or moved row shows locked, and a device without the account's keys reads and writes nothing. The Inbox is the row under an ID keyed by the account, so every device makes the same one and the instance can't tell which it is; it can't be deleted. Settings shows Sync's card first and the cog its dot while sync fails, as Learn's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3876e30681
commit
60a8f5b617
21 changed files with 1285 additions and 36 deletions
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Todo needs the same thing.
|
||||
|
||||
- **A shell on the shared packages** (decided 2026-10-19; what Todo is to do and how it looks: ADR 0033, decided 2026-11-01): signing in, the instances, the account and the look work; nothing of a person's is kept or synced yet, which waits for Todo's first model (the developer decides what it keeps and syncs). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
|
||||
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, no data of its own to delete with a person yet (add a `deletePersonData` hook with Todo's first table). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`) and `deletion-notices` (`account_deletion_notice`), nothing of Todo's own. Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
|
||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Today (where it opens), Upcoming, Inbox and Browse, each saying tasks come later until they do (`lib/src/tabs/`), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog, grouped in `SectionCard`s: the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
|
||||
- **A shell on the shared packages** (decided 2026-10-19; what Todo is to do and how it looks: ADR 0033, decided 2026-11-01): signing in, the instances, the account and the look work; since 1 Nov 2026 a person's projects are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0033, D5, D6): `content_key` and `project` on the server (`deleteTodoData` deletes a person's space with them), and on the device `TodoKeys` (Train's `TrainKeys` copied with Todo's own key IDs, `todo1.…`; to be shared in halkyon_app once Train's 0.1.1 is out), `ProjectRepository` (the Inbox under a row ID keyed by the account, made once as the person's data opens, never deleted) and `TodoPerson` opening the person's sync database, its `SyncService` and both, as Train's. Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
|
||||
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteTodoData]`, `lib/src/todo_data.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`), `deletion-notices` (`account_deletion_notice`), and since 1 Nov 2026 `projects` (`content_key`, `project` and the sync engine's tables). Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
|
||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Today (where it opens), Upcoming, Inbox and Browse, each saying tasks come later until they do (`lib/src/tabs/`), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
|
||||
- **The icon** is Lucide's `list-todo` fading from cobalt through pacific into fern (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py todo` from the root): `assets/icon/halkyon_todo_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts the notice in the licence list.
|
||||
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.
|
||||
|
|
|
|||
|
|
@ -79,13 +79,23 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
|
|||
localizationsDelegates: todoLocalizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localeListResolutionCallback: resolveAppLocale,
|
||||
// How many accounts wait, for whoever operates the instance: Settings'
|
||||
// operator card and the badge on Browse and its cog.
|
||||
// How many accounts wait, for whoever operates the instance (Settings'
|
||||
// operator card and the badge on Browse and its cog), and where sync
|
||||
// stands.
|
||||
builder: (context, child) => PersonScope(
|
||||
person: widget.person,
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
OperatingCubit(AppScope.of(context).instanceOperators),
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
OperatingCubit(AppScope.of(context).instanceOperators),
|
||||
),
|
||||
// Where sync stands: Settings' card and the dot on the cog.
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
SyncStatusBloc(PersonScope.of<TodoPerson>(context).sync),
|
||||
),
|
||||
],
|
||||
child: ScreenColor(
|
||||
color: PaletteColor.strawberry.background,
|
||||
child: child!,
|
||||
|
|
|
|||
|
|
@ -1,24 +1,44 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' show Client;
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart'
|
||||
show Client, UuidValue;
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||
import 'package:serverpod_flutter/serverpod_flutter.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// A signed-in person's dependencies: who they are, and nothing else yet, as
|
||||
/// Todo keeps nothing of a person's until its first model (no local
|
||||
/// database, no sync). halkyon_app's `PersonScope` opens and disposes them
|
||||
/// with the person.
|
||||
/// A signed-in person's dependencies: who they are and their projects, on
|
||||
/// their own local database in their folder, which the sync engine keeps in
|
||||
/// step with their other devices, every row sealed with their key (ADR 0033,
|
||||
/// D5, D6). halkyon_app's `PersonScope` opens and disposes them with the
|
||||
/// person.
|
||||
class TodoPerson(
|
||||
/// Whose they are.
|
||||
final SignedIn person,
|
||||
) implements Disposable {
|
||||
final SignedIn person, {
|
||||
|
||||
/// Their sync with the instance: where it stands, for Settings' card.
|
||||
required final SyncService sync,
|
||||
|
||||
/// Their projects, the Inbox among them, sealed and synced.
|
||||
required final ProjectRepository projects,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) implements Disposable {
|
||||
/// Closes what was opened for the person.
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
Future<void> dispose() async {
|
||||
await _onDispose?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a signed-in person's dependencies ([TodoDependencies.openPerson]).
|
||||
|
|
@ -112,6 +132,13 @@ class TodoDependencies({
|
|||
// As the session above: an account waiting for the operator shows its
|
||||
// wait, not the start screen.
|
||||
await authGateway.initialize();
|
||||
// Whether the server still works with this build: asked before the
|
||||
// first sync of each launch.
|
||||
final compatibility = AppCompatibility(
|
||||
appBuild: device.appBuild,
|
||||
minimumAppBuild: () async =>
|
||||
(await client.modules.base.instance.basics()).minimumAppBuild,
|
||||
);
|
||||
final people = device.storage.peopleOf(instance);
|
||||
final folders = PersonFolders(people);
|
||||
final addresses = FileSignInAddresses(people);
|
||||
|
|
@ -146,6 +173,8 @@ class TodoDependencies({
|
|||
ServerpodAssistantOperatorGateway(client.modules.base),
|
||||
ServerpodInstanceOperatorGateway(client.modules.base),
|
||||
);
|
||||
// The last person's data closing, before the next one's opens.
|
||||
var previousClosed = Future<void>.value();
|
||||
return TodoDependencies(
|
||||
serverName: instance.name,
|
||||
auth: AuthRepository(authGateway, addresses),
|
||||
|
|
@ -159,9 +188,29 @@ class TodoDependencies({
|
|||
sourceCodeUrl: () async =>
|
||||
(await client.modules.base.instance.basics()).sourceCodeUrl,
|
||||
openPerson: (person) async {
|
||||
// One person's folder on the device at a time, as Learn keeps it.
|
||||
await folders.openOnly(person.authUserId);
|
||||
return TodoPerson(person);
|
||||
// The person before may still be closing their database, which may
|
||||
// be this one's (signed in again): waited for, but not for ever.
|
||||
await previousClosed.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {},
|
||||
);
|
||||
final closed = Completer<void>();
|
||||
previousClosed = closed.future;
|
||||
try {
|
||||
return await _openPersonData(
|
||||
client,
|
||||
compatibility,
|
||||
person,
|
||||
// One person's folder on the device at a time, as Learn keeps it.
|
||||
await folders.openOnly(person.authUserId),
|
||||
accountKeys: () => accountKeys.read(person.authUserId),
|
||||
onClosed: closed.complete,
|
||||
);
|
||||
} on Object {
|
||||
// Nothing was opened, so there is nothing to wait for.
|
||||
closed.complete();
|
||||
rethrow;
|
||||
}
|
||||
},
|
||||
onDispose: () async {
|
||||
await passwords.dispose();
|
||||
|
|
@ -182,6 +231,73 @@ class TodoDependencies({
|
|||
}
|
||||
}
|
||||
|
||||
/// Opens [person]'s own local database in [folder] (see [PersonFolders]), its
|
||||
/// sync, their key and their projects, the Inbox made where it isn't yet;
|
||||
/// [onClosed] runs once all is closed again.
|
||||
Future<TodoPerson> _openPersonData(
|
||||
Client client,
|
||||
AppCompatibility compatibility,
|
||||
SignedIn person,
|
||||
Directory folder, {
|
||||
required Future<UnlockedAccountKeys?> Function() accountKeys,
|
||||
required void Function() onClosed,
|
||||
}) async {
|
||||
final db = await client.createSyncSession(
|
||||
p.join(folder.path, 'sync.sqlite'),
|
||||
persistentUserId: UuidValue.withValidation(person.authUserId),
|
||||
);
|
||||
final kept = FilePendingChangesStore(
|
||||
File(p.join(folder.path, 'sync_pending.json')),
|
||||
);
|
||||
final sync = SyncService(
|
||||
compatibleSync(
|
||||
({required onMerge}) => client.offlineSync.syncOnce(
|
||||
db,
|
||||
// Only when changes from other devices came in: a sync that only
|
||||
// sent this device's changes has nothing new to read.
|
||||
onInboundMerge: (space) => onMerge(),
|
||||
),
|
||||
compatibility,
|
||||
),
|
||||
pendingChanges: kept,
|
||||
syncedBefore: (await kept.read()).synced,
|
||||
)..start();
|
||||
// The person's key, with which everything of theirs is sealed (D5); read
|
||||
// again whenever the projects are, as a sync may have brought it.
|
||||
final keys = TodoKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
SodiumContentCipher(),
|
||||
accountKeys,
|
||||
);
|
||||
await keys.refresh();
|
||||
final projects = ProjectRepository(SyncProjectGateway(db, sync), keys);
|
||||
// The Inbox, made once by whichever device is first; nothing where this
|
||||
// device holds none of the account's keys.
|
||||
unawaited(
|
||||
projects.ensureInbox().then<void>(
|
||||
(_) {},
|
||||
onError: (Object error) =>
|
||||
log('The Inbox not made: $error', name: 'Projects'),
|
||||
),
|
||||
);
|
||||
return TodoPerson(
|
||||
person,
|
||||
sync: sync,
|
||||
projects: projects,
|
||||
onDispose: () async {
|
||||
try {
|
||||
await projects.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
} finally {
|
||||
// The key opened here goes once nothing can write any more.
|
||||
keys.dispose();
|
||||
onClosed();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// What the dependencies on every instance share: the device's own parts,
|
||||
/// made once when the app starts, outliving every switch of instance.
|
||||
class TodoDevice({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// A `content_key` row (ADR 0033, D5; Train's ADR 0030, D15): the key's ID
|
||||
/// as the row says it, which is never trusted, and the key sealed to the account.
|
||||
typedef ContentKeyRow = ({String dataKeyId, Uint8List sealedKey});
|
||||
|
||||
/// Where the person's data keys are kept: their synced `content_key` rows.
|
||||
abstract interface class ContentKeyGateway() {
|
||||
/// Every key row of the person's.
|
||||
Future<List<ContentKeyRow>> all();
|
||||
|
||||
/// Keeps a new key.
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required Uint8List sealedKey,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/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 SyncService _sync,
|
||||
) implements ContentKeyGateway {
|
||||
@override
|
||||
Future<List<ContentKeyRow>> all() async => [
|
||||
for (final key in await protocol.ContentKey.db.find(
|
||||
_db,
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
(
|
||||
dataKeyId: key.dataKeyId,
|
||||
sealedKey: Uint8List.sublistView(key.sealedKey),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required Uint8List sealedKey,
|
||||
}) async {
|
||||
await protocol.ContentKey.db.insertRow(
|
||||
_db,
|
||||
protocol.ContentKey(
|
||||
dataKeyId: dataKeyId,
|
||||
sealedKey: ByteData.sublistView(sealedKey),
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/content_key_gateway.dart';
|
||||
|
||||
/// Refused: this device holds none of the account's keys (signed in without
|
||||
/// them), so it opens none of the person's data and writes none (ADR 0033,
|
||||
/// D5; as Train's ADR 0030, D15, and Learn's ADR 0019 have it).
|
||||
final class KeysMissing() implements Exception {
|
||||
@override
|
||||
String toString() => "KeysMissing: the account's keys aren't on this device";
|
||||
}
|
||||
|
||||
/// The person's key on this device (ADR 0033, D5; Train's `TrainKeys`, copied
|
||||
/// with Todo's own IDs, ADR 0030, D15): one key, with which
|
||||
/// everything of Todo's is sealed (ADR 0033, D5), kept in their synced `content_key`
|
||||
/// rows sealed to the account, so their other devices open it.
|
||||
///
|
||||
/// A key's ID is sealed with it and signed by the account (halkyon_keys'
|
||||
/// `sealDataKey` opens a key only under the ID it was sealed with), so the
|
||||
/// instance can't relabel one, and only IDs of Todo's own form are opened:
|
||||
/// a key the account sealed for another app (Learn's personal key) never
|
||||
/// counts here. Two devices that each made a key while offline both write
|
||||
/// with the older once they see both; every key that opens still reads.
|
||||
class TodoKeys(
|
||||
final ContentKeyGateway _gateway,
|
||||
|
||||
/// Seals and opens with the keys; [TodoKeys] owns it.
|
||||
final ContentCipher cipher,
|
||||
|
||||
/// The account's keys on this device; null where none are.
|
||||
final Future<UnlockedAccountKeys?> Function() _accountKeys,
|
||||
) {
|
||||
var _unlocked = false;
|
||||
String? _writeKey;
|
||||
|
||||
/// Reads the key rows again and opens the person's keys among them: as
|
||||
/// the person's data opens, and after a sync brought others' rows.
|
||||
Future<void> refresh() async {
|
||||
final account = await _accountKeys();
|
||||
if (account == null) {
|
||||
return;
|
||||
}
|
||||
if (!_unlocked) {
|
||||
await cipher.unlock(account);
|
||||
_unlocked = true;
|
||||
}
|
||||
for (final row in await _gateway.all()) {
|
||||
final id = row.dataKeyId;
|
||||
if (_madeAt(id) != null &&
|
||||
(cipher.has(id) || cipher.openKey(id, row.sealedKey)) &&
|
||||
_older(id, _writeKey)) {
|
||||
_writeKey = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the account's keys are open here: [refresh] found them.
|
||||
bool get unlocked => _unlocked;
|
||||
|
||||
/// The ID of the row named [name], keyed by the account (D57): the same
|
||||
/// on each of its devices, nothing of [name] read from it by the
|
||||
/// instance. Throws [KeysMissing] where [refresh] found no keys.
|
||||
String rowId(String name) =>
|
||||
_unlocked ? cipher.rowId(name) : throw KeysMissing();
|
||||
|
||||
/// Whether what is sealed with the key [keyId] opens here.
|
||||
bool canOpen(String keyId) =>
|
||||
_unlocked && _madeAt(keyId) != null && cipher.has(keyId);
|
||||
|
||||
/// The key to seal with, made and kept the first time; throws
|
||||
/// [KeysMissing] without the account's keys. The key rows are read first
|
||||
/// where none was chosen yet, so that no second key is made beside one
|
||||
/// already there.
|
||||
Future<String> keyToWrite() async {
|
||||
if (_writeKey == null) {
|
||||
await refresh();
|
||||
}
|
||||
if (_writeKey case final key?) {
|
||||
return key;
|
||||
}
|
||||
if (!_unlocked) {
|
||||
throw KeysMissing();
|
||||
}
|
||||
final random = Random.secure();
|
||||
final nonce = base64Url
|
||||
.encode([for (var i = 0; i < 12; i++) random.nextInt(256)])
|
||||
.replaceAll('=', '');
|
||||
final id = 'todo1.${clock.now().toUtc().millisecondsSinceEpoch}.$nonce';
|
||||
await _gateway.insert(dataKeyId: id, sealedKey: cipher.newKey(id));
|
||||
return _writeKey = id;
|
||||
}
|
||||
|
||||
/// Wipes the keys kept open here.
|
||||
void dispose() => cipher.dispose();
|
||||
|
||||
/// When the key [id] was made, as its ID says (`todo1.<ms>.<nonce>`);
|
||||
/// null for an ID of another form, which is never Todo's.
|
||||
static int? _madeAt(String id) => switch (id.split('.')) {
|
||||
['todo1', final ms, final nonce] when nonce.isNotEmpty => int.tryParse(ms),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Whether [id] was made before [other] (none at all: yes), ties going to
|
||||
/// the smaller ID, so every device picks the same.
|
||||
static bool _older(String id, String? other) {
|
||||
if (other == null) {
|
||||
return true;
|
||||
}
|
||||
final (a, b) = (_madeAt(id)!, _madeAt(other)!);
|
||||
return a < b || (a == b && id.compareTo(other) < 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// A `project` row as kept: its id and its envelope, sealed (ADR 0033, D5).
|
||||
typedef ProjectRow = ({String id, Envelope envelope});
|
||||
|
||||
/// Where the person's projects are kept: their synced `project` rows
|
||||
/// (D6). The repository seals and opens; this only keeps.
|
||||
abstract interface class ProjectGateway() {
|
||||
Future<List<ProjectRow>> projects();
|
||||
|
||||
Future<void> insert(ProjectRow project);
|
||||
|
||||
/// Replaces the project [id]'s envelope (an edit); nothing for one gone.
|
||||
Future<void> update(String id, Envelope envelope);
|
||||
|
||||
/// Deletes the project [id], its payload blanked first (ADR 0002, rule 2).
|
||||
Future<void> delete(String id);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
/// The schema of a project's payload this app writes and reads.
|
||||
const projectSchemaVersion = 1;
|
||||
|
||||
/// [content] as its row's payload, before sealing: UTF-8 JSON, the colour by
|
||||
/// its palette name.
|
||||
ByteData encodeProject(ProjectContent content) => ByteData.sublistView(
|
||||
Uint8List.fromList(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'schemaVersion': projectSchemaVersion,
|
||||
'name': content.name,
|
||||
'color': ?content.color?.name,
|
||||
'position': content.position,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// The project [payload] holds, opened; null for one of a newer schema or one
|
||||
/// it can't read. A colour it doesn't know is none.
|
||||
ProjectContent? decodeProject(ByteData payload) {
|
||||
try {
|
||||
final json = jsonDecode(
|
||||
utf8.decode(Uint8List.sublistView(payload)),
|
||||
) as Map<String, Object?>;
|
||||
final version = json['schemaVersion']! as int;
|
||||
if (version > projectSchemaVersion) {
|
||||
log('A project of schema $version skipped', name: 'Project');
|
||||
return null;
|
||||
}
|
||||
final color = json['color'] as String?;
|
||||
return ProjectContent(
|
||||
name: json['name']! as String,
|
||||
color: PaletteColor.values.where((c) => c.name == color).firstOrNull,
|
||||
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log("A project's payload not read: $error", name: 'Project');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' show Uuid;
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
|
||||
|
||||
/// Refused: the Inbox stays as long as the person does (ADR 0033, D6).
|
||||
final class InboxKept() implements Exception {
|
||||
@override
|
||||
String toString() => "InboxKept: the Inbox can't be deleted";
|
||||
}
|
||||
|
||||
/// The person's projects (ADR 0033, D6), each sealed with their key for its
|
||||
/// row (D5); a readable row is one the instance made up, and shows locked.
|
||||
///
|
||||
/// The Inbox is the row under an ID keyed by the account
|
||||
/// ([TodoKeys.rowId], Train's ADR 0030, D57): every device of the person's
|
||||
/// makes the same one, so two that make it offline write one row, and the
|
||||
/// instance can tell neither which row it is nor whose.
|
||||
class ProjectRepository(final ProjectGateway _gateway, final TodoKeys _keys) {
|
||||
StreamSubscription<void>? _remote;
|
||||
|
||||
/// This device's changes, and those a sync brings while anyone listens.
|
||||
late final StreamController<void> _changes = StreamController.broadcast(
|
||||
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
|
||||
onCancel: () => _remote?.cancel(),
|
||||
);
|
||||
|
||||
late final _envelope = PayloadEnvelope(_keys.cipher);
|
||||
|
||||
/// Fires when the projects changed, here or on the person's other devices.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// The Inbox's row ID; throws [KeysMissing] without the account's keys.
|
||||
String get inboxId => _keys.rowId('todo/project/inbox');
|
||||
|
||||
/// Every project, the Inbox first, then by their position, ties by when
|
||||
/// they were made; one that doesn't open here with its content null.
|
||||
Future<List<StoredProject>> all() async {
|
||||
await _keys.refresh();
|
||||
final inbox = _keys.unlocked ? inboxId : null;
|
||||
final projects = [
|
||||
for (final row in await _gateway.projects())
|
||||
StoredProject(row.id, switch (_open(row.envelope, row.id)) {
|
||||
final payload? => decodeProject(payload),
|
||||
null => null,
|
||||
}, inbox: row.id == inbox),
|
||||
];
|
||||
return projects..sort(_order);
|
||||
}
|
||||
|
||||
/// Makes the Inbox where it isn't yet and returns its ID; throws
|
||||
/// [KeysMissing] where this device can't seal.
|
||||
Future<String> ensureInbox() async {
|
||||
await _keys.refresh();
|
||||
final id = inboxId;
|
||||
if (!(await _gateway.projects()).any((row) => row.id == id)) {
|
||||
await _gateway.insert((
|
||||
id: id,
|
||||
envelope: await _sealed(id, ProjectContent.inbox),
|
||||
));
|
||||
_changes.add(null);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Keeps a new project and returns its ID; throws [KeysMissing] where this
|
||||
/// device can't seal.
|
||||
Future<String> save(ProjectContent content) async {
|
||||
final id = const Uuid().v7();
|
||||
await _gateway.insert((id: id, envelope: await _sealed(id, content)));
|
||||
_changes.add(null);
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> update(String id, ProjectContent content) async {
|
||||
await _gateway.update(id, await _sealed(id, content));
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Deletes the project [id]; throws [InboxKept] for the Inbox.
|
||||
Future<void> delete(String id) async {
|
||||
if (_keys.unlocked && id == inboxId) {
|
||||
throw InboxKept();
|
||||
}
|
||||
await _gateway.delete(id);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Closes what it holds open.
|
||||
Future<void> dispose() => _changes.close();
|
||||
|
||||
Future<Envelope> _sealed(String id, ProjectContent content) async =>
|
||||
_envelope.seal(
|
||||
encodeProject(content),
|
||||
keyId: await _keys.keyToWrite(),
|
||||
table: 'project',
|
||||
id: id,
|
||||
);
|
||||
|
||||
ByteData? _open(Envelope envelope, String id) {
|
||||
if (envelope.keyId == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (_envelope.open(envelope, table: 'project', id: id)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => null,
|
||||
};
|
||||
}
|
||||
|
||||
static int _order(StoredProject a, StoredProject b) {
|
||||
if (a.inbox != b.inbox) {
|
||||
return a.inbox ? -1 : 1;
|
||||
}
|
||||
final byPosition = (a.content?.position ?? double.infinity).compareTo(
|
||||
b.content?.position ?? double.infinity,
|
||||
);
|
||||
return byPosition != 0 ? byPosition : a.id.compareTo(b.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [ProjectGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
class SyncProjectGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements ProjectGateway {
|
||||
@override
|
||||
Future<List<ProjectRow>> projects() async => [
|
||||
for (final project in await protocol.Project.db.find(
|
||||
_db,
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
(
|
||||
id: project.id!.uuid,
|
||||
envelope: (
|
||||
keyId: project.keyId,
|
||||
nonce: project.nonce,
|
||||
payload: project.payload,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(ProjectRow project) async {
|
||||
await protocol.Project.db.insertRow(
|
||||
_db,
|
||||
protocol.Project(
|
||||
id: protocol.UuidValue.withValidation(project.id),
|
||||
keyId: project.envelope.keyId,
|
||||
nonce: project.envelope.nonce,
|
||||
payload: project.envelope.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (await protocol.Project.db.findById(
|
||||
_db,
|
||||
protocol.UuidValue.withValidation(id),
|
||||
)
|
||||
case final project?) {
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.Project.db.updateRow(
|
||||
_db,
|
||||
project.copyWith(
|
||||
keyId: envelope.keyId,
|
||||
nonce: envelope.nonce,
|
||||
payload: envelope.payload,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
final uuid = protocol.UuidValue.withValidation(id);
|
||||
await _db.db.transaction((transaction) async {
|
||||
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||
// they last were, until the space goes.
|
||||
if (await protocol.Project.db.findById(
|
||||
_db,
|
||||
uuid,
|
||||
transaction: transaction,
|
||||
)
|
||||
case final project?) {
|
||||
await protocol.Project.db.updateRow(
|
||||
_db,
|
||||
protocol.Project(
|
||||
id: project.id,
|
||||
spaceId: project.spaceId,
|
||||
payload: ByteData(0),
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
await protocol.Project.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.equals(uuid),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
/// What a project is (ADR 0033, D6), as its sealed payload holds it: its
|
||||
/// [name], its [color] from halkyon_ui's palette (D9; none: the theme's),
|
||||
/// and its [position] among the person's projects, smaller first. The Inbox
|
||||
/// is told by its row's ID, not by anything here (`ProjectRepository`), and
|
||||
/// shows under the app's own word for it, whatever [name] holds.
|
||||
final class const ProjectContent({
|
||||
required final String name,
|
||||
final PaletteColor? color,
|
||||
final double position = 0,
|
||||
}) with Equatable {
|
||||
/// The Inbox's content, as the first device writes it.
|
||||
static const inbox = ProjectContent(name: '');
|
||||
|
||||
ProjectContent copyWith({
|
||||
String? name,
|
||||
PaletteColor? Function()? color,
|
||||
double? position,
|
||||
}) => ProjectContent(
|
||||
name: name ?? this.name,
|
||||
color: color == null ? this.color : color(),
|
||||
position: position ?? this.position,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, color, position];
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
|
||||
/// A project as the person's devices keep it: its [id], whether it is their
|
||||
/// [inbox], and what it is, or [content] null where it doesn't open here:
|
||||
/// shown as locked, never half.
|
||||
final class const StoredProject(
|
||||
final String id,
|
||||
final ProjectContent? content, {
|
||||
final bool inbox = false,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, content, inbox];
|
||||
}
|
||||
|
|
@ -14,13 +14,16 @@ class const SettingsButton({super.key}) extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) => IconButton(
|
||||
tooltip: AppLocalizations.of(context).settingsTitle,
|
||||
icon: const WaitingAccountsBadge(child: Icon(LucideIcons.settings)),
|
||||
// The dot of a failing sync, which the Sync card explains, as Learn's.
|
||||
icon: const SyncTroubleBadge(
|
||||
child: WaitingAccountsBadge(child: Icon(LucideIcons.settings)),
|
||||
),
|
||||
onPressed: () => context.push(TodoPaths.settings),
|
||||
);
|
||||
}
|
||||
|
||||
/// Settings, over the tabs (ADR 0033, D4), grouped in cards as Learn's and
|
||||
/// Train's: the operator's card for whoever operates the instance; Todo's
|
||||
/// Train's: where sync stands; the operator's card for whoever operates the instance; Todo's
|
||||
/// own groups as their settings come (none is shown empty); the account,
|
||||
/// near the foot (halkyon_app's sections: whose it is on which instance,
|
||||
/// switching, signing out and deleting it, SimpleLogin, its password and
|
||||
|
|
@ -51,6 +54,10 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Where sync stands, first, as Learn's: the cog's dot leads
|
||||
// here.
|
||||
const SyncStatusCard(),
|
||||
const SizedBox(height: 12),
|
||||
// Accounts waiting for approval, for whoever operates
|
||||
// the instance alone (Halkyon Learn's ADR 0021, D34),
|
||||
// first, as the cog's count leads here.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ environment:
|
|||
resolution: workspace
|
||||
|
||||
dependencies:
|
||||
clock: ^1.1.3
|
||||
equatable: ^3.0.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^9.1.1
|
||||
|
|
@ -45,6 +47,7 @@ dependencies:
|
|||
path_provider: ^2.1.6
|
||||
serverpod_auth_idp_flutter: 4.0.4
|
||||
serverpod_flutter: 4.0.4
|
||||
serverpod_offline_sync_client: 0.0.10
|
||||
|
||||
dev_dependencies:
|
||||
flutter_driver:
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ import 'package:halkyon_app/halkyon_app.dart';
|
|||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/memory_content_key_gateway.dart';
|
||||
import '../support/memory_project_gateway.dart';
|
||||
|
||||
void main() {
|
||||
final builtIn = Instance.parse('todo.example.org')!;
|
||||
late MemoryInstanceStore store;
|
||||
|
|
@ -86,7 +91,26 @@ void main() {
|
|||
'https://code.halkyon.app/project-halkyon/halkyon',
|
||||
openPerson: (person) async {
|
||||
opened.add(person);
|
||||
return TodoPerson(person);
|
||||
final keys = TodoKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
SodiumContentCipher(),
|
||||
() async => null,
|
||||
);
|
||||
final sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
debounce: const Duration(days: 1),
|
||||
);
|
||||
final projects = ProjectRepository(MemoryProjectGateway(), keys);
|
||||
return TodoPerson(
|
||||
person,
|
||||
sync: sync,
|
||||
projects: projects,
|
||||
onDispose: () async {
|
||||
await projects.dispose();
|
||||
await sync.dispose();
|
||||
keys.dispose();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -212,19 +236,23 @@ void main() {
|
|||
expect(find.widgetWithText(AppBar, 'Browse'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Settings has the account, then About Halkyon Todo last and '
|
||||
'the version small at the very foot', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openSettings(tester);
|
||||
testWidgets(
|
||||
'Settings has Sync first, the account, then About Halkyon Todo last and '
|
||||
'the version small at the very foot',
|
||||
(tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openSettings(tester);
|
||||
|
||||
final order = [
|
||||
'Account',
|
||||
'Sign out',
|
||||
'About Halkyon Todo',
|
||||
'Version 0.1.0 (build 1)',
|
||||
].map((text) => tester.getTopLeft(find.text(text)).dy).toList();
|
||||
expect(order, [...order]..sort());
|
||||
});
|
||||
final order = [
|
||||
'Sync',
|
||||
'Account',
|
||||
'Sign out',
|
||||
'About Halkyon Todo',
|
||||
'Version 0.1.0 (build 1)',
|
||||
].map((text) => tester.getTopLeft(find.text(text)).dy).toList();
|
||||
expect(order, [...order]..sort());
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets("from medium windows on, a rail with Todo's icon on top", (
|
||||
tester,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
|
||||
/// The person's key on Todo's devices (ADR 0033, D5, as Train's ADR 0030, D15), with libsodium as
|
||||
/// its build hook compiles it.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late MemoryContentKeyGateway rows;
|
||||
final opened = <TodoKeys>[];
|
||||
|
||||
/// A device of the account whose keys are [account] (none: signed in
|
||||
/// without them), on [rows].
|
||||
Future<TodoKeys> device(UnlockedAccountKeys? account) async {
|
||||
final keys = TodoKeys(rows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return keys;
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
bob = (await crypto.newAccount('bob password')).keys;
|
||||
});
|
||||
|
||||
setUp(() => rows = MemoryContentKeyGateway());
|
||||
|
||||
tearDown(() {
|
||||
for (final keys in opened) {
|
||||
keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
});
|
||||
|
||||
test("the first write makes the person's key, which their other devices "
|
||||
'open', () async {
|
||||
final phone = await device(alice);
|
||||
expect(rows.rows, isEmpty);
|
||||
|
||||
final keyId = await phone.keyToWrite();
|
||||
|
||||
expect(rows.rows.map((row) => row.dataKeyId), [keyId]);
|
||||
expect(keyId, startsWith('todo1.'));
|
||||
final laptop = await device(alice);
|
||||
expect(laptop.canOpen(keyId), isTrue);
|
||||
expect(await laptop.keyToWrite(), keyId);
|
||||
expect(rows.rows, hasLength(1));
|
||||
});
|
||||
|
||||
test('a device that has read no key rows yet writes with the key already '
|
||||
'there, and makes none beside it', () async {
|
||||
final phone = await device(alice);
|
||||
final keyId = await phone.keyToWrite();
|
||||
final laptop = TodoKeys(rows, SodiumContentCipher(), () async => alice);
|
||||
opened.add(laptop);
|
||||
|
||||
expect(await laptop.keyToWrite(), keyId);
|
||||
expect(rows.rows, hasLength(1));
|
||||
});
|
||||
|
||||
test('two devices that made a key each, offline, both write with the '
|
||||
'older one once they see both', () async {
|
||||
final phone = await device(alice);
|
||||
final laptop = await device(alice);
|
||||
final older = await withClock(
|
||||
Clock.fixed(DateTime.utc(2026, 10, 28, 8)),
|
||||
phone.keyToWrite,
|
||||
);
|
||||
// The laptop hasn't seen the phone's yet: as offline.
|
||||
final row = rows.rows.removeLast();
|
||||
final newer = await withClock(
|
||||
Clock.fixed(DateTime.utc(2026, 10, 28, 9)),
|
||||
laptop.keyToWrite,
|
||||
);
|
||||
rows.rows.add(row);
|
||||
|
||||
await phone.refresh();
|
||||
await laptop.refresh();
|
||||
|
||||
expect(await phone.keyToWrite(), older);
|
||||
expect(await laptop.keyToWrite(), older);
|
||||
expect(laptop.canOpen(newer), isTrue);
|
||||
expect(phone.canOpen(newer), isTrue);
|
||||
});
|
||||
|
||||
test("a device without the account's keys opens nothing and writes "
|
||||
'nothing', () async {
|
||||
final phone = await device(alice);
|
||||
final keyId = await phone.keyToWrite();
|
||||
|
||||
final elsewhere = await device(null);
|
||||
|
||||
expect(elsewhere.canOpen(keyId), isFalse);
|
||||
expect(elsewhere.keyToWrite, throwsA(isA<KeysMissing>()));
|
||||
expect(rows.rows, hasLength(1));
|
||||
});
|
||||
|
||||
test("another account's key, put among the person's by the instance, is "
|
||||
'never opened or written with', () async {
|
||||
final bobs = await device(bob);
|
||||
final bobsKey = await bobs.keyToWrite();
|
||||
|
||||
final phone = await device(alice);
|
||||
|
||||
expect(phone.canOpen(bobsKey), isFalse);
|
||||
expect(await phone.keyToWrite(), isNot(bobsKey));
|
||||
});
|
||||
|
||||
test('a key relabelled by the instance, or one the account sealed for '
|
||||
'another app, is never opened', () async {
|
||||
final phone = await device(alice);
|
||||
final keyId = await phone.keyToWrite();
|
||||
// The same sealed key under an ID that would sort before it.
|
||||
rows.rows.add((
|
||||
dataKeyId: 'todo1.1.relabelled',
|
||||
sealedKey: rows.rows.single.sealedKey,
|
||||
));
|
||||
// A key of the same account sealed under Learn's ID for its personal
|
||||
// key, which would open as what it was sealed for.
|
||||
final learn = SodiumContentCipher();
|
||||
await learn.unlock(alice);
|
||||
addTearDown(learn.dispose);
|
||||
rows.rows.add((
|
||||
dataKeyId: 'p1.1.learn',
|
||||
sealedKey: learn.newKey('p1.1.learn'),
|
||||
));
|
||||
|
||||
final laptop = await device(alice);
|
||||
|
||||
expect(laptop.canOpen('todo1.1.relabelled'), isFalse);
|
||||
expect(laptop.canOpen('p1.1.learn'), isFalse);
|
||||
expect(await laptop.keyToWrite(), keyId);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
/// A project's payload (ADR 0033, D6): UTF-8 JSON with its schema's version,
|
||||
/// read back as it was written, and never read half.
|
||||
void main() {
|
||||
ByteData json(Object value) =>
|
||||
ByteData.sublistView(Uint8List.fromList(utf8.encode(jsonEncode(value))));
|
||||
|
||||
test('a project reads back as it was written', () {
|
||||
const home = ProjectContent(
|
||||
name: 'Home',
|
||||
color: PaletteColor.fern,
|
||||
position: 2.5,
|
||||
);
|
||||
|
||||
expect(decodeProject(encodeProject(home)), home);
|
||||
expect(
|
||||
decodeProject(encodeProject(ProjectContent.inbox)),
|
||||
ProjectContent.inbox,
|
||||
);
|
||||
});
|
||||
|
||||
test('the payload names its schema and the colour by its name', () {
|
||||
final written = jsonDecode(
|
||||
utf8.decode(
|
||||
Uint8List.sublistView(
|
||||
encodeProject(
|
||||
const ProjectContent(
|
||||
name: 'Home',
|
||||
color: PaletteColor.fern,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
) as Map<String, Object?>;
|
||||
|
||||
expect(written, {
|
||||
'schemaVersion': 1,
|
||||
'name': 'Home',
|
||||
'color': 'fern',
|
||||
'position': 0.0,
|
||||
});
|
||||
});
|
||||
|
||||
test("a colour this app doesn't know is none; a newer schema, a payload "
|
||||
'without a name or one that is no JSON is skipped', () {
|
||||
expect(
|
||||
decodeProject(json({'schemaVersion': 1, 'name': 'A', 'color': 'mauve'})),
|
||||
const ProjectContent(name: 'A'),
|
||||
);
|
||||
expect(decodeProject(json({'schemaVersion': 2, 'name': 'A'})), isNull);
|
||||
expect(decodeProject(json({'schemaVersion': 1})), isNull);
|
||||
expect(decodeProject(ByteData(0)), isNull);
|
||||
expect(
|
||||
decodeProject(ByteData.sublistView(Uint8List.fromList([0xff, 0]))),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_project_gateway.dart';
|
||||
|
||||
/// The person's projects, sealed on the way to the instance and opened on
|
||||
/// the way back (ADR 0033, D5, D6), with libsodium.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late MemoryContentKeyGateway keyRows;
|
||||
late MemoryProjectGateway rows;
|
||||
final opened = <TodoKeys>[];
|
||||
|
||||
const home = ProjectContent(
|
||||
name: 'Home',
|
||||
color: PaletteColor.fern,
|
||||
position: 1,
|
||||
);
|
||||
|
||||
Future<ProjectRepository> device(UnlockedAccountKeys? account) async {
|
||||
final keys = TodoKeys(keyRows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return ProjectRepository(rows, keys);
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
bob = (await crypto.newAccount('bob password')).keys;
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
keyRows = MemoryContentKeyGateway();
|
||||
rows = MemoryProjectGateway();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
for (final keys in opened) {
|
||||
keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
});
|
||||
|
||||
group('the Inbox', () {
|
||||
test('is made once, under the same ID on every device of the account, '
|
||||
'and first', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.ensureInbox();
|
||||
await phone.save(home);
|
||||
|
||||
final laptop = await device(alice);
|
||||
expect(await laptop.ensureInbox(), id);
|
||||
expect(rows.rows.keys.where((key) => key == id), hasLength(1));
|
||||
final all = await laptop.all();
|
||||
expect(all.first, StoredProject(id, ProjectContent.inbox, inbox: true));
|
||||
expect(all.where((project) => project.inbox), hasLength(1));
|
||||
});
|
||||
|
||||
test('two devices making it offline write one row, not two', () async {
|
||||
final phone = await device(alice);
|
||||
final laptop = await device(alice);
|
||||
|
||||
// Neither sees the other's write before making its own.
|
||||
final ids = await Future.wait([
|
||||
phone.ensureInbox(),
|
||||
laptop.ensureInbox(),
|
||||
]);
|
||||
|
||||
expect(ids.toSet(), hasLength(1));
|
||||
expect(rows.rows, hasLength(1));
|
||||
});
|
||||
|
||||
test("names nothing of the person's that the instance could read: its "
|
||||
"ID isn't the person's nor the same for another account", () async {
|
||||
final mine = await (await device(alice)).ensureInbox();
|
||||
final theirs = await (await device(bob)).ensureInbox();
|
||||
|
||||
expect(mine, isNot(theirs));
|
||||
});
|
||||
|
||||
test("can't be deleted", () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.ensureInbox();
|
||||
|
||||
await expectLater(phone.delete(id), throwsA(isA<InboxKept>()));
|
||||
expect(rows.rows.keys, contains(id));
|
||||
});
|
||||
|
||||
test('is only the row under its ID: a row claiming to be it elsewhere is '
|
||||
'just a project', () async {
|
||||
final phone = await device(alice);
|
||||
final other = await phone.save(home);
|
||||
|
||||
final all = await phone.all();
|
||||
expect(all.single, StoredProject(other, home));
|
||||
});
|
||||
});
|
||||
|
||||
test('a project made on one device reads on another of the account, '
|
||||
'edited and deleted there', () async {
|
||||
final laptop = await device(alice);
|
||||
final id = await laptop.save(home);
|
||||
final phone = await device(alice);
|
||||
expect(await phone.all(), [StoredProject(id, home)]);
|
||||
|
||||
final renamed = home.copyWith(name: 'House', color: () => null);
|
||||
await phone.update(id, renamed);
|
||||
expect(await laptop.all(), [StoredProject(id, renamed)]);
|
||||
|
||||
await laptop.delete(id);
|
||||
expect(await phone.all(), isEmpty);
|
||||
});
|
||||
|
||||
test('projects come in their order, the Inbox first', () async {
|
||||
final phone = await device(alice);
|
||||
final second = await phone.save(home.copyWith(name: 'B', position: 2));
|
||||
final first = await phone.save(home.copyWith(name: 'A', position: 1));
|
||||
final inbox = await phone.ensureInbox();
|
||||
|
||||
expect(
|
||||
[for (final project in await phone.all()) project.id],
|
||||
[
|
||||
inbox,
|
||||
first,
|
||||
second,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('nothing the instance gets is readable: no name, no colour, no '
|
||||
'JSON', () async {
|
||||
await (await device(alice)).save(home);
|
||||
|
||||
final sealed = rows.rows.values.single;
|
||||
expect(sealed.keyId, isNotNull);
|
||||
final text = utf8.decode(
|
||||
Uint8List.sublistView(sealed.payload),
|
||||
allowMalformed: true,
|
||||
);
|
||||
expect(text, isNot(contains('Home')));
|
||||
expect(text, isNot(contains('fern')));
|
||||
expect(text, isNot(contains('schemaVersion')));
|
||||
});
|
||||
|
||||
test("another person's project, a readable one, or one moved to another "
|
||||
'row shows locked, never as theirs', () async {
|
||||
final id = await (await device(alice)).save(home);
|
||||
final sealed = rows.rows[id]!;
|
||||
rows.rows
|
||||
..['moved'] = sealed
|
||||
..['readable'] = (
|
||||
keyId: null,
|
||||
nonce: null,
|
||||
payload: ByteData.sublistView(
|
||||
Uint8List.fromList(utf8.encode('{"schemaVersion":1,"name":"X"}')),
|
||||
),
|
||||
);
|
||||
|
||||
final others = await device(bob);
|
||||
expect(
|
||||
(await others.all()).where((project) => project.content != null),
|
||||
isEmpty,
|
||||
);
|
||||
final mine = await device(alice);
|
||||
expect(
|
||||
{for (final project in await mine.all()) project.id: project.content},
|
||||
{id: home, 'moved': null, 'readable': null},
|
||||
);
|
||||
});
|
||||
|
||||
test('an Inbox sealed for another row shows locked, never as the '
|
||||
"person's Inbox", () async {
|
||||
final phone = await device(alice);
|
||||
final other = await phone.save(home);
|
||||
final inbox = phone.inboxId;
|
||||
rows.rows[inbox] = rows.rows[other]!;
|
||||
|
||||
final all = await phone.all();
|
||||
expect(all.firstWhere((project) => project.inbox).content, isNull);
|
||||
});
|
||||
|
||||
test('a device without the keys reads nothing and saves nothing, not '
|
||||
'even the Inbox', () async {
|
||||
await (await device(alice)).save(home);
|
||||
final stranger = await device(null);
|
||||
|
||||
expect((await stranger.all()).single.content, isNull);
|
||||
await expectLater(stranger.save(home), throwsA(isA<KeysMissing>()));
|
||||
await expectLater(stranger.ensureInbox(), throwsA(isA<KeysMissing>()));
|
||||
expect(rows.rows, hasLength(1));
|
||||
});
|
||||
|
||||
test('a change here or from another device is told', () async {
|
||||
final phone = await device(alice);
|
||||
var told = 0;
|
||||
final listening = phone.changes.listen((_) => told++);
|
||||
|
||||
await phone.save(home);
|
||||
rows.bringRemoteChanges();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(told, 2);
|
||||
await listening.cancel();
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
// The projects on the device's own database, as the sync engine keeps them,
|
||||
// with libsodium as its build hook compiles it.
|
||||
|
||||
final _person = protocol.UuidValue.withValidation(
|
||||
'01a16000-0000-7000-8000-000000000001',
|
||||
);
|
||||
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late Directory folder;
|
||||
late OfflineSyncDatabaseSession db;
|
||||
late SyncService sync;
|
||||
late TodoKeys keys;
|
||||
late ProjectRepository projects;
|
||||
|
||||
const home = ProjectContent(name: 'Home', position: 1);
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('projects');
|
||||
db = await protocol.Client(
|
||||
'http://127.0.0.1:9/',
|
||||
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
|
||||
sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
debounce: const Duration(days: 1),
|
||||
);
|
||||
keys = TodoKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
SodiumContentCipher(),
|
||||
() async => alice,
|
||||
);
|
||||
await keys.refresh();
|
||||
projects = ProjectRepository(SyncProjectGateway(db, sync), keys);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await projects.dispose();
|
||||
keys.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
await folder.delete(recursive: true);
|
||||
});
|
||||
|
||||
test(
|
||||
'the Inbox and a project are kept, edited and read back opened',
|
||||
() async {
|
||||
final inbox = await projects.ensureInbox();
|
||||
expect(await projects.ensureInbox(), inbox);
|
||||
final id = await projects.save(home);
|
||||
const renamed = ProjectContent(name: 'House', position: 1);
|
||||
|
||||
await projects.update(id, renamed);
|
||||
|
||||
expect(await projects.all(), [
|
||||
StoredProject(inbox, ProjectContent.inbox, inbox: true),
|
||||
StoredProject(id, renamed),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('deleting a project blanks its payload first', () async {
|
||||
final id = await projects.save(home);
|
||||
final kept = await projects.save(home);
|
||||
|
||||
await projects.delete(id);
|
||||
|
||||
expect(await projects.all(), [StoredProject(kept, home)]);
|
||||
// The sync engine keeps the deleted row as its tombstone: blank.
|
||||
final rows = await db.db.unsafeQuery(
|
||||
'SELECT length("payload"), "keyId", "nonce" FROM "project"',
|
||||
);
|
||||
final blank = [
|
||||
for (final row in rows)
|
||||
if (row[0] == 0) row,
|
||||
];
|
||||
expect(rows.length - blank.length, 1);
|
||||
expect(blank, hasLength(1));
|
||||
expect(blank.single.skip(1), [null, null]);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/content_key_gateway.dart';
|
||||
|
||||
/// [ContentKeyGateway] in memory, which tests share between "devices" as the
|
||||
/// sync engine would, and change as an instance could.
|
||||
class MemoryContentKeyGateway() implements ContentKeyGateway {
|
||||
/// The rows, as the instance holds them.
|
||||
final rows = <ContentKeyRow>[];
|
||||
|
||||
@override
|
||||
Future<List<ContentKeyRow>> all() async => List.of(rows);
|
||||
|
||||
@override
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required Uint8List sealedKey,
|
||||
}) async => rows.add((dataKeyId: dataKeyId, sealedKey: sealedKey));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue