mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 21:27:01 +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>
96 lines
3.3 KiB
Dart
96 lines
3.3 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:todo/images/image_processing.dart';
|
|
|
|
/// A JPEG built in-test (the binary gate forbids committed fixtures), with
|
|
/// GPS coordinates and an EXIF rotation, like a phone photo.
|
|
Uint8List phonePhoto({int width = 4000, int height = 3000}) {
|
|
final photo = img.Image(width: width, height: height)
|
|
..clear(img.ColorRgb8(200, 30, 30));
|
|
photo.exif.imageIfd.orientation = 6;
|
|
photo.exif.gpsIfd['GPSLatitude'] = img.IfdValueRational(52, 1);
|
|
return img.encodeJpg(photo);
|
|
}
|
|
|
|
void main() {
|
|
test('the fixture really carries GPS and a rotation', () {
|
|
final exif = img.decodeJpgExif(phonePhoto(width: 40, height: 30))!;
|
|
expect(exif.gpsIfd.isEmpty, isFalse);
|
|
expect(exif.imageIfd.orientation, 6);
|
|
});
|
|
|
|
test('shrinks, applies the rotation and drops all EXIF', () async {
|
|
final out = (await prepareImageInBackground(phonePhoto()))!;
|
|
final back = img.decodeJpg(out)!;
|
|
// Landscape pixels + "rotate 90" => upright portrait, long side capped.
|
|
expect(back.width, 1920);
|
|
expect(back.height, kMaxImageSide);
|
|
expect(img.decodeJpgExif(out)?.isEmpty ?? true, isTrue);
|
|
});
|
|
|
|
test('caps a landscape image on its width', () {
|
|
final wide = img.encodeJpg(img.Image(width: 3000, height: 1000));
|
|
final back = img.decodeJpg(prepareImage(wide)!)!;
|
|
expect(back.width, kMaxImageSide);
|
|
});
|
|
|
|
test('leaves a small image at its size', () {
|
|
final small = img.encodePng(img.Image(width: 300, height: 200));
|
|
final back = img.decodeJpg(prepareImage(small)!)!;
|
|
expect((back.width, back.height), (300, 200));
|
|
});
|
|
|
|
test('flattens transparency onto white', () {
|
|
final clear = img.Image(width: 8, height: 8, numChannels: 4);
|
|
final back = img.decodeJpg(prepareImage(img.encodePng(clear))!)!;
|
|
final pixel = back.getPixel(4, 4);
|
|
expect([pixel.r, pixel.g, pixel.b], everyElement(greaterThan(250)));
|
|
});
|
|
|
|
test('non-images come back null, including ones the decoder throws on', () {
|
|
expect(prepareImage(Uint8List.fromList([1, 2, 3])), isNull);
|
|
expect(prepareImage(Uint8List.fromList('hello world'.codeUnits)), isNull);
|
|
expect(prepareImage(Uint8List(0)), isNull);
|
|
});
|
|
|
|
test('looksLikeImage knows the photo and screenshot formats', () {
|
|
final formats = {
|
|
'jpeg': img.encodeJpg(img.Image(width: 2, height: 2)),
|
|
'png': img.encodePng(img.Image(width: 2, height: 2)),
|
|
'gif': img.encodeGif(img.Image(width: 2, height: 2)),
|
|
'bmp': img.encodeBmp(img.Image(width: 2, height: 2)),
|
|
'tiff': img.encodeTiff(img.Image(width: 2, height: 2)),
|
|
'webp': Uint8List.fromList([
|
|
...'RIFF'.codeUnits,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
...'WEBP'.codeUnits,
|
|
]),
|
|
'tiff big-endian': Uint8List.fromList([0x4D, 0x4D, 0x00, 0x2A]),
|
|
};
|
|
formats.forEach((name, bytes) {
|
|
expect(
|
|
looksLikeImage(bytes.take(kImageSniffBytes).toList()),
|
|
isTrue,
|
|
reason: name,
|
|
);
|
|
});
|
|
});
|
|
|
|
test('looksLikeImage refuses everything else', () {
|
|
for (final head in [
|
|
'%PDF-1.7'.codeUnits,
|
|
'hello world'.codeUnits,
|
|
[...'RIFF'.codeUnits, 0, 0, 0, 0, ...'AVI '.codeUnits],
|
|
[0xFF, 0xD8],
|
|
<int>[],
|
|
]) {
|
|
expect(looksLikeImage(head), isFalse, reason: '$head');
|
|
}
|
|
});
|
|
}
|