refactor(app,learn): people's files on the device shared in halkyon_app

ADR 0043's recommendation, for Todo's attachments: what Learn had for its
files on the device moves into halkyon_app, unchanged but for its names,
and Learn uses it from there.

- FileCache, DirectoryFileCache (Learn's AssetFileCache): the files by
  their SHA-256 in a folder of the person's, and which wait to be
  uploaded.
- FileServerGateway, ServerpodFileServerGateway (Learn's
  AssetServerGateway): halkyon_base's files endpoint, taking the module's
  caller rather than Learn's client; FileLimitReached, FileRoom.
- Their fakes in halkyon_app's testing: MemoryFileCache,
  FakeFileServerGateway, sha256Of.
- SealedFile: the format every app seals a file in (HLKF, version 1),
  sealing with halkyon_app's ContentCipher; Learn's Privacy seals and
  opens its files with it, for the same context as before, so every file
  sealed so far opens as it did.

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 01:17:22 +01:00
commit 5dd7575dd6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,62 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:path/path.dart' as p;
void main() {
late Directory folder;
late DirectoryFileCache cache;
final hash = 'a' * 64;
final other = 'b' * 64;
setUp(() async {
folder = await Directory.systemTemp.createTemp('files');
cache = DirectoryFileCache(Directory(p.join(folder.path, 'files')));
});
tearDown(() => folder.delete(recursive: true));
test('keeps a file by its hash, and has none it was not given', () async {
expect(await cache.read(hash), isNull);
await cache.write(hash, Uint8List.fromList([1, 2, 3]));
expect(await cache.read(hash), [1, 2, 3]);
expect(File(cache.pathOf(hash)).readAsBytesSync(), [1, 2, 3]);
expect(
Directory(p.join(folder.path, 'files')).listSync().map((e) => e.path),
[cache.pathOf(hash)],
reason: 'nothing half-written is left beside it',
);
});
test('keeps the file it has when given it again', () async {
await cache.write(hash, Uint8List.fromList([1]));
await cache.write(hash, Uint8List.fromList([2]));
expect(await cache.read(hash), [1]);
});
test('notes which files wait to be uploaded', () async {
expect(await cache.pendingUploads(), isEmpty);
await cache.markPending(hash);
await cache.markPending(other);
await cache.clearPending(hash);
await cache.clearPending('c' * 64);
expect(await cache.pendingUploads(), {other});
});
test('refuses anything but a SHA-256 in a path', () async {
for (final forged in ['../sync.sqlite', 'A' * 64, '${'a' * 63}/', '']) {
expect(() => cache.pathOf(forged), throwsArgumentError);
await expectLater(cache.markPending(forged), throwsArgumentError);
await expectLater(
cache.write(forged, Uint8List.fromList([1])),
throwsArgumentError,
);
}
});
}