feat(todo,app): files attached to tasks and comments
Halkyon's ADR 0043, D1, D3, D5 and D8: any file up to 100 MB, attached to a task's description or to a comment, sealed as everything of Todo's. Server: the attachment table, synced and sealed as the others, with the sealed file's hash readable for the store; todoFiles hands halkyon_base its rows, readable by their space's members, and takes sealed files alone; the files no attachment names go daily. Its tests hold downloads to the row's space, refuse a readable file and prune what no live row names. Migration attachments: CREATE TABLE "attachment" with its indexes and the foreign key to its space, cascading as every synced table's. App: AttachmentRepository seals each file for its row, keeps it sealed and opened in the person's folder, uploads it after each sync and fetches it as it shows, both held back by Without files on mobile data but for a file tapped. Under the description and a comment's text, a row of them: pictures as thumbnails opening full-size, other files as chips opening in the system's app, else saved; the paperclip beside the description and in the comment's composer adds them, the composer holding them until it is sent. A task deleted takes its files and its comments' along, a comment its own. halkyon_app opens a file in the system's app (FileOpener): on Android through its plugin and a FileProvider of its own class, sharing the cache's halkyon_opened/ folder alone; on the desktop as a file: URI. Whats-New: Attach files to a task's description or a comment with the paperclip: pictures show as thumbnails, other files open in their app. Whats-New-De: Hänge mit der Büroklammer Dateien an die Beschreibung einer Aufgabe oder an einen Kommentar: Bilder erscheinen als Vorschau, andere Dateien öffnen sich in ihrer App. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5dd7575dd6
commit
3ca4b4308d
1 changed files with 23300 additions and 62 deletions
|
|
@ -0,0 +1,276 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/attachment/data/attachment_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/attachment/data/attachment_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/attachment/model/attachment.dart';
|
||||
|
||||
import '../../support/memory_attachment_gateway.dart';
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
|
||||
/// Files attached to tasks and comments (Halkyon's ADR 0043): sealed, the
|
||||
/// rows and the files alike, on the way to the instance, and opened on the
|
||||
/// way back, with libsodium.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late MemoryContentKeyGateway keyRows;
|
||||
late MemoryAttachmentGateway rows;
|
||||
late FakeFileServerGateway server;
|
||||
final opened = <TodoKeys>[];
|
||||
final repositories = <AttachmentRepository>[];
|
||||
|
||||
final photo = (
|
||||
name: 'receipt.png',
|
||||
mimeType: 'image/png',
|
||||
bytes: Uint8List.fromList(utf8.encode('a receipt, drawn')),
|
||||
);
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
bob = (await crypto.newAccount('bob password')).keys;
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
keyRows = MemoryContentKeyGateway();
|
||||
rows = MemoryAttachmentGateway();
|
||||
server = FakeFileServerGateway();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final repository in repositories) {
|
||||
await repository.dispose();
|
||||
}
|
||||
repositories.clear();
|
||||
for (final keys in opened) {
|
||||
keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
});
|
||||
|
||||
/// A device of [account]'s, with its own files, uploading on [triggers].
|
||||
Future<AttachmentRepository> device(
|
||||
UnlockedAccountKeys? account, {
|
||||
MemoryFileCache? files,
|
||||
Stream<void> triggers = const Stream.empty(),
|
||||
bool Function()? filesAllowed,
|
||||
}) async {
|
||||
final keys = TodoKeys(keyRows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
final repository = AttachmentRepository(
|
||||
rows,
|
||||
keys,
|
||||
server,
|
||||
files ?? MemoryFileCache(),
|
||||
uploadTriggers: triggers,
|
||||
filesAllowed: filesAllowed ?? () => true,
|
||||
);
|
||||
repositories.add(repository);
|
||||
return repository;
|
||||
}
|
||||
|
||||
/// [server]'s downloads serve each row's file, as the instance would.
|
||||
void serveRows() {
|
||||
for (final row in rows.byId.values) {
|
||||
server.rows[row.id] = row.sha256;
|
||||
}
|
||||
}
|
||||
|
||||
test("a payload comes back as it went; a later schema's is not read", () {
|
||||
final content = AttachmentContent(
|
||||
taskId: 't1',
|
||||
commentId: 'c1',
|
||||
name: 'receipt.png',
|
||||
mimeType: 'image/png',
|
||||
byteSize: 16,
|
||||
sha256: 'a' * 64,
|
||||
addedAt: DateTime.utc(2026, 11, 11, 9),
|
||||
);
|
||||
expect(decodeAttachment(encodeAttachment(content)), content);
|
||||
expect(
|
||||
decodeAttachment(encodeJson(attachmentSchemaVersion + 1, {})),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('a file attached on one device opens on another of the account; the '
|
||||
'instance sees neither it nor its name', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.add(photo, taskId: 't1');
|
||||
await phone.uploadPending();
|
||||
serveRows();
|
||||
|
||||
final stored = server.files.values.single;
|
||||
expect(
|
||||
utf8.decode(stored, allowMalformed: true),
|
||||
isNot(contains('receipt')),
|
||||
);
|
||||
expect(server.encrypted, {rows.byId[id]!.sha256});
|
||||
expect(
|
||||
utf8.decode(
|
||||
Uint8List.sublistView(rows.byId[id]!.envelope.payload),
|
||||
allowMalformed: true,
|
||||
),
|
||||
isNot(contains('receipt')),
|
||||
);
|
||||
|
||||
final laptop = await device(alice);
|
||||
final attachment = (await laptop.all()).single;
|
||||
expect(attachment.content?.name, 'receipt.png');
|
||||
expect(attachment.content?.taskId, 't1');
|
||||
expect(await laptop.isHere(attachment), isFalse);
|
||||
expect(await laptop.fileOf(attachment), photo.bytes);
|
||||
expect(await laptop.isHere(attachment), isTrue);
|
||||
expect(server.downloads, [id]);
|
||||
// Kept once fetched: shown again without asking.
|
||||
await laptop.fileOf(attachment);
|
||||
expect(server.downloads, [id]);
|
||||
});
|
||||
|
||||
test("another person's attachment opens neither as a row nor as a "
|
||||
'file', () async {
|
||||
final phone = await device(alice);
|
||||
await phone.add(photo, taskId: 't1');
|
||||
await phone.uploadPending();
|
||||
serveRows();
|
||||
|
||||
final hers = await device(bob);
|
||||
final attachment = (await hers.all()).single;
|
||||
expect(attachment.content, isNull);
|
||||
expect(await hers.fileOf(attachment), isNull);
|
||||
});
|
||||
|
||||
test('a file the instance changed, or moved to another row, never '
|
||||
'opens', () async {
|
||||
final phone = await device(alice);
|
||||
final first = await phone.add(photo, taskId: 't1');
|
||||
final second = await phone.add(
|
||||
(name: 'b.txt', mimeType: 'text/plain', bytes: utf8.encode('other')),
|
||||
taskId: 't1',
|
||||
);
|
||||
await phone.uploadPending();
|
||||
// The first row names the second's file, which the server serves for
|
||||
// it; another file of the first's served under its own hash.
|
||||
final third = await phone.add(photo, taskId: 't2');
|
||||
await phone.uploadPending();
|
||||
server.rows[first] = rows.byId[second]!.sha256;
|
||||
rows.byId[first] = (
|
||||
id: first,
|
||||
sha256: rows.byId[second]!.sha256,
|
||||
envelope: rows.byId[first]!.envelope,
|
||||
);
|
||||
server.rows[third] = rows.byId[third]!.sha256;
|
||||
server.files[rows.byId[third]!.sha256] = Uint8List.fromList(
|
||||
[...server.files[rows.byId[third]!.sha256]!]..last ^= 1,
|
||||
);
|
||||
|
||||
final laptop = await device(alice);
|
||||
final attachments = {for (final a in await laptop.all()) a.id: a};
|
||||
expect(await laptop.fileOf(attachments[first]!), isNull);
|
||||
expect(await laptop.fileOf(attachments[third]!), isNull);
|
||||
expect(await laptop.fileOf(attachments[second]!), utf8.encode('other'));
|
||||
});
|
||||
|
||||
test('uploads wait while files are held back on mobile data, and so do '
|
||||
'downloads', () async {
|
||||
var allowed = false;
|
||||
final triggers = StreamController<void>.broadcast();
|
||||
addTearDown(triggers.close);
|
||||
final phone = await device(
|
||||
alice,
|
||||
triggers: triggers.stream,
|
||||
filesAllowed: () => allowed,
|
||||
);
|
||||
await phone.add(photo, taskId: 't1');
|
||||
await phone.uploadPending();
|
||||
expect(server.started, isEmpty);
|
||||
|
||||
allowed = true;
|
||||
triggers.add(null);
|
||||
await pumpEventQueue();
|
||||
expect(server.files, hasLength(1));
|
||||
|
||||
serveRows();
|
||||
var laptopAllowed = false;
|
||||
final laptop = await device(alice, filesAllowed: () => laptopAllowed);
|
||||
final attachment = (await laptop.all()).single;
|
||||
expect(await laptop.fileOf(attachment), isNull);
|
||||
expect(server.downloads, isEmpty);
|
||||
laptopAllowed = true;
|
||||
expect(await laptop.fileOf(attachment), photo.bytes);
|
||||
});
|
||||
|
||||
test('a failed upload waits for the next round', () async {
|
||||
final phone = await device(alice);
|
||||
server.failCalls = Exception('offline');
|
||||
await phone.add(photo, taskId: 't1');
|
||||
await phone.uploadPending();
|
||||
expect(server.files, isEmpty);
|
||||
|
||||
server.failCalls = null;
|
||||
await phone.uploadPending();
|
||||
expect(server.files, hasLength(1));
|
||||
});
|
||||
|
||||
test('an attachment deleted before its upload is never uploaded', () async {
|
||||
server.failCalls = Exception('offline');
|
||||
final phone = await device(alice);
|
||||
final id = await phone.add(photo, taskId: 't1');
|
||||
await phone.delete([id]);
|
||||
server.failCalls = null;
|
||||
await phone.uploadPending();
|
||||
expect(server.started, isEmpty);
|
||||
});
|
||||
|
||||
test("goes with its task, or with its comment, and nobody else's", () async {
|
||||
final phone = await device(alice);
|
||||
final onTask = await phone.add(photo, taskId: 't1');
|
||||
final onComment = await phone.add(photo, taskId: 't2', commentId: 'c1');
|
||||
final kept = await phone.add(photo, taskId: 't3');
|
||||
|
||||
await phone.deleteOnTasks({'t1'});
|
||||
await phone.deleteOnComments({'c1'});
|
||||
|
||||
expect([for (final a in await phone.all()) a.id], [kept]);
|
||||
expect(rows.byId.keys, isNot(contains(onTask)));
|
||||
expect(rows.byId.keys, isNot(contains(onComment)));
|
||||
});
|
||||
|
||||
test('refuses an empty file and one over 100 MB; a device without keys '
|
||||
'attaches nothing', () async {
|
||||
final phone = await device(alice);
|
||||
await expectLater(
|
||||
phone.add(
|
||||
(name: 'empty', mimeType: 'text/plain', bytes: Uint8List(0)),
|
||||
taskId: 't1',
|
||||
),
|
||||
throwsArgumentError,
|
||||
);
|
||||
await expectLater(
|
||||
phone.add(
|
||||
(
|
||||
name: 'big',
|
||||
mimeType: 'video/mp4',
|
||||
bytes: Uint8List(AttachmentRepository.maxBytes + 1),
|
||||
),
|
||||
taskId: 't1',
|
||||
),
|
||||
throwsArgumentError,
|
||||
);
|
||||
|
||||
final keyless = await device(null);
|
||||
await expectLater(
|
||||
keyless.add(photo, taskId: 't1'),
|
||||
throwsA(isA<KeysMissing>()),
|
||||
);
|
||||
expect(rows.byId, isEmpty);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue