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
3 changed files with 272 additions and 235 deletions

View file

@ -1,106 +0,0 @@
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart';
/// An [AssetServerGateway] in memory, holding files by hash as the server
/// does, and asset rows' files by id for downloads.
class FakeAssetServerGateway() implements AssetServerGateway {
/// The files the server keeps, by hash.
final files = <String, Uint8List>{};
/// The hash of each asset row the server knows, by id: what a download
/// serves.
final rows = <String, String>{};
/// What was uploaded and not finished yet, by hash.
final _uploaded = <String, Uint8List>{};
/// The hashes uploads were started for, in order.
final started = <String>[];
/// The ids downloads were asked for, in order.
final downloads = <String>[];
/// The hashes of the uploads said to be encrypted (ADR 0019).
final encrypted = <String>{};
/// When set, every call fails with it, as without a connection.
Exception? failCalls;
/// When true, uploads don't arrive.
bool failUploads = false;
/// When set, uploads arrive with this content instead of what was sent.
Uint8List? corruptUploads;
/// What the person may keep, in bytes; past it, uploads are refused.
int limitBytes = 1 << 30;
int get _usedBytes =>
files.values.fold(0, (sum, bytes) => sum + bytes.length);
@override
Future<String?> startUpload(
String sha256,
int byteSize, {
bool encrypted = false,
}) async {
_check();
started.add(sha256);
if (encrypted) {
this.encrypted.add(sha256);
}
if (files.containsKey(sha256)) {
return null;
}
if (_usedBytes + byteSize > limitBytes) {
throw FileLimitReached(instanceFull: false, limitBytes: limitBytes);
}
return 'upload:$sha256';
}
@override
Future<bool> upload(String description, Uint8List bytes) async {
_check();
if (failUploads) {
return false;
}
_uploaded[description.substring('upload:'.length)] =
corruptUploads ?? bytes;
return true;
}
@override
Future<bool> finishUpload(String sha256, {bool encrypted = false}) async {
_check();
final bytes = _uploaded.remove(sha256);
if (bytes == null || sha256Of(bytes) != sha256) {
return false;
}
files[sha256] = bytes;
return true;
}
@override
Future<FileRoom> room() async {
_check();
return (usedBytes: _usedBytes, limitBytes: limitBytes);
}
@override
Future<Uint8List?> download(String id) async {
_check();
downloads.add(id);
return files[rows[id]];
}
void _check() {
if (failCalls case final error?) {
throw error;
}
}
}
/// The SHA-256 of [bytes] in lowercase hex.
String sha256Of(List<int> bytes) => sha256.convert(bytes).toString();

View file

@ -1,31 +0,0 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart';
/// An [AssetFileCache] in memory.
class MemoryAssetFileCache() implements AssetFileCache {
/// The files on the "device", by hash.
final files = <String, Uint8List>{};
/// The hashes of the files waiting to be uploaded.
final pending = <String>{};
@override
Future<Uint8List?> read(String sha256) async => files[sha256];
@override
Future<void> write(String sha256, Uint8List bytes) async =>
files[checkedSha256(sha256)] = bytes;
@override
String pathOf(String sha256) => '/cache/${checkedSha256(sha256)}';
@override
Future<Set<String>> pendingUploads() async => {...pending};
@override
Future<void> markPending(String sha256) async => pending.add(sha256);
@override
Future<void> clearPending(String sha256) async => pending.remove(sha256);
}

View file

@ -3,10 +3,8 @@ import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/sound_player.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
@ -80,7 +78,6 @@ import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'fake_asset_gateway.dart';
import 'fake_asset_sealing.dart';
import 'fake_asset_server_gateway.dart';
import 'fake_card_gateway.dart';
import 'fake_conversation_gateway.dart';
import 'fake_device_dictionaries.dart';
@ -105,7 +102,6 @@ import 'fake_sound_player.dart';
import 'fake_studyset_gateway.dart';
import 'fake_tag_gateway.dart';
import 'fake_youtube_videos.dart';
import 'memory_asset_file_cache.dart';
import 'memory_device_settings_store.dart';
import 'memory_pomodoro_run_store.dart';
import 'memory_published_store.dart';
@ -147,8 +143,8 @@ Dependencies testDependencies({
SettingsGateway? settingsGateway,
FakeConversationGateway? conversationGateway,
AssetGateway? assetGateway,
AssetServerGateway? assetServer,
AssetFileCache? assetFiles,
FileServerGateway? assetServer,
FileCache? assetFiles,
Future<String> Function()? sourceCodeUrl,
DictionaryGateway? dictionaryGateway,
DeviceDictionaries? deviceDictionaries,
@ -315,8 +311,8 @@ Dependencies testDependencies({
);
final assets = AssetRepository(
assetGateway ?? FakeAssetGateway(),
assetServer ?? FakeAssetServerGateway(),
assetFiles ?? MemoryAssetFileCache(),
assetServer ?? FakeFileServerGateway(),
assetFiles ?? MemoryFileCache(),
FakeAssetSealing(),
);
final pdfSources = PdfSourceRepository(