mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 13:47:45 +01:00
Some checks failed
📎 now picks any file (Android: Camera / Gallery / File; desktop: an
unfiltered dialog) and the desktop page accepts any dropped file. Photo and
screenshot formats up to 64 MB are still re-encoded and shown inline;
everything else is stored byte for byte on dufs beside the images and linked
as [name](url), shown as a row with its size that opens in the platform's
app (Android) or a new browser tab (desktop). BACKLOG.md rewrites file links
to ~/data/cloud/todo-images/ like image links.
There is no size cap, so no layer holds a file in memory: uploads and
downloads stream through DufsImageClient, writes land in a .tmp renamed when
whole (an interrupted download no longer leaves a truncated file that looks
done), the desktop page POSTs the browser's own Blob with fetch, and Android's
"File" uses file_picker, because file_selector's Android side reads the whole
file into memory on the UI thread. Verified with a 1 GB file end to end on
desktop (peak RSS +36 MB) and on an API 36 emulator.
The wrapper serves attachments from the origin that holds the GitHub token
and the notes, so only script-free types open inline (nosniff), the rest
download; writes carrying another site's Origin are refused (this also
closes cross-site overwrites of the backup files); and attachment routes run
concurrently so a long upload or a playing video cannot stall the page.
Also: cupertino_icons 2.0.0 (unused, but the freshness gate blocks the
pubspec otherwise), open_file 4.0.0 and file_picker 13.1.0 pinned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
5.0 KiB
Dart
146 lines
5.0 KiB
Dart
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:todo/images/attachment_ingest.dart';
|
|
import 'package:todo/images/image_cache_store.dart';
|
|
import 'package:todo/images/image_processing.dart';
|
|
|
|
void main() {
|
|
late Directory root;
|
|
late ImageCacheStore store;
|
|
late List<int> prepared;
|
|
|
|
final png = img.encodePng(img.Image(width: 4, height: 3));
|
|
final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, 1]);
|
|
|
|
AttachmentIngest ingest({Uint8List? Function(Uint8List)? prepare}) =>
|
|
AttachmentIngest(
|
|
store,
|
|
prepare: (bytes) async {
|
|
prepared.add(bytes.length);
|
|
return (prepare ?? (_) => jpeg)(bytes);
|
|
},
|
|
);
|
|
|
|
setUp(() {
|
|
root = Directory.systemTemp.createTempSync('ingest');
|
|
store = ImageCacheStore(Directory(p.join(root.path, 'store')));
|
|
prepared = [];
|
|
});
|
|
tearDown(() => root.deleteSync(recursive: true));
|
|
|
|
void expectNoTemps() => expect(
|
|
store.root.listSync(recursive: true).where((e) => e.path.endsWith('.tmp')),
|
|
isEmpty,
|
|
);
|
|
|
|
test('a non-image is stored byte for byte as a queued file', () async {
|
|
final pdf = '%PDF-1.7 hello'.codeUnits;
|
|
final a = await ingest().fromStream('n1', 'Raport.pdf', Stream.value(pdf));
|
|
expect(a.isImage, isFalse);
|
|
expect(a.name, 'Raport.pdf');
|
|
expect(a.ref.extension, 'pdf');
|
|
expect(store.fileFor(a.ref).readAsBytesSync(), pdf);
|
|
expect(store.isPending(a.ref), isTrue);
|
|
expect(prepared, isEmpty, reason: 'never decoded');
|
|
expectNoTemps();
|
|
});
|
|
|
|
test('an image is re-encoded and stored as a jpg', () async {
|
|
final a = await ingest().fromStream('n1', 'shot.png', Stream.value(png));
|
|
expect(a.isImage, isTrue);
|
|
expect(a.ref.extension, 'jpg');
|
|
expect(store.fileFor(a.ref).readAsBytesSync(), jpeg);
|
|
expect(store.isPending(a.ref), isTrue);
|
|
expect(prepared, [png.length]);
|
|
expectNoTemps();
|
|
});
|
|
|
|
test('the real preparer is the default', () async {
|
|
final a = await AttachmentIngest(
|
|
store,
|
|
).fromStream('n1', 'shot.png', Stream.value(png));
|
|
expect(img.decodeJpg(store.fileFor(a.ref).readAsBytesSync()), isNotNull);
|
|
});
|
|
|
|
test('an image that will not decode is kept as a file', () async {
|
|
final a = await ingest(
|
|
prepare: (_) => null,
|
|
).fromStream('n1', 'broken.png', Stream.value(png));
|
|
expect(a.isImage, isFalse);
|
|
expect(a.ref.extension, 'png');
|
|
expect(store.fileFor(a.ref).readAsBytesSync(), png);
|
|
});
|
|
|
|
test('an image too big to decode is kept as a file, unread', () async {
|
|
final chunk = Uint8List(1024 * 1024);
|
|
Stream<List<int>> huge() async* {
|
|
yield png.sublist(0, kImageSniffBytes);
|
|
for (var i = 0; i < kMaxImageDecodeBytes ~/ chunk.length; i++) {
|
|
yield chunk;
|
|
}
|
|
}
|
|
|
|
final a = await ingest().fromStream('n1', 'huge.png', huge());
|
|
expect(a.isImage, isFalse);
|
|
expect(prepared, isEmpty);
|
|
expect(
|
|
store.fileFor(a.ref).lengthSync(),
|
|
kMaxImageDecodeBytes + kImageSniffBytes,
|
|
);
|
|
});
|
|
|
|
test('a failed read leaves nothing behind', () async {
|
|
Stream<List<int>> broken() async* {
|
|
yield [1, 2];
|
|
throw const FileSystemException('gone');
|
|
}
|
|
|
|
await expectLater(
|
|
ingest().fromStream('n1', 'a.bin', broken()),
|
|
throwsA(isA<FileSystemException>()),
|
|
);
|
|
expectNoTemps();
|
|
expect(store.pending(), isEmpty);
|
|
});
|
|
|
|
test('fromFile copies a file it does not own', () async {
|
|
final source = File(p.join(root.path, 'mine.txt'))..writeAsStringSync('x');
|
|
final a = await ingest().fromFile('n1', 'mine.txt', source, move: false);
|
|
expect(store.fileFor(a.ref).readAsStringSync(), 'x');
|
|
expect(source.existsSync(), isTrue);
|
|
});
|
|
|
|
test('fromFile moves a picker copy instead of duplicating it', () async {
|
|
final source = File(p.join(root.path, 'copy.txt'))..writeAsStringSync('x');
|
|
final a = await ingest().fromFile('n1', 'copy.txt', source, move: true);
|
|
expect(store.fileFor(a.ref).readAsStringSync(), 'x');
|
|
expect(source.existsSync(), isFalse);
|
|
expectNoTemps();
|
|
});
|
|
|
|
test('a failure after the move cleans up the moved file', () async {
|
|
final source = File(p.join(root.path, 'copy.png'))..writeAsBytesSync(png);
|
|
await expectLater(
|
|
AttachmentIngest(
|
|
store,
|
|
prepare: (_) async => throw const FileSystemException('disk full'),
|
|
).fromFile('n1', 'copy.png', source, move: true),
|
|
throwsA(isA<FileSystemException>()),
|
|
);
|
|
expectNoTemps();
|
|
});
|
|
|
|
test('fromFile falls back to copy-and-delete across filesystems', () async {
|
|
final shm = Directory('/dev/shm').createTempSync('ingest');
|
|
addTearDown(() => shm.deleteSync(recursive: true));
|
|
final source = File(p.join(shm.path, 'far.txt'))..writeAsStringSync('y');
|
|
final a = await ingest().fromFile('n1', 'far.txt', source, move: true);
|
|
expect(store.fileFor(a.ref).readAsStringSync(), 'y');
|
|
expect(source.existsSync(), isFalse);
|
|
}, skip: Directory('/dev/shm').existsSync() ? false : 'needs /dev/shm');
|
|
}
|