mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 22:47: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>
101 lines
3.7 KiB
Dart
101 lines
3.7 KiB
Dart
/// Shrinks a picked or pasted image to what a note needs, before it is stored
|
||
/// or uploaded.
|
||
///
|
||
/// Phone photos are 3–8 MB with GPS coordinates in their EXIF. Every image is
|
||
/// re-encoded here: the recorded rotation is applied to the pixels first (so
|
||
/// dropping EXIF does not leave a portrait photo lying on its side), the long
|
||
/// side is capped at [kMaxImageSide], and the result is a JPEG with no
|
||
/// metadata at all.
|
||
///
|
||
/// Pure Dart (`package:image`), so the Android app and the desktop wrapper
|
||
/// run the same code; the web page never decodes images, it hands the raw
|
||
/// bytes to the wrapper.
|
||
library;
|
||
|
||
import 'dart:isolate';
|
||
import 'dart:math' as math;
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:image/image.dart' as img;
|
||
|
||
/// Longest side, in pixels, of a stored image.
|
||
const kMaxImageSide = 2560;
|
||
|
||
/// JPEG quality of a stored image.
|
||
const kJpegQuality = 85;
|
||
|
||
/// Largest attachment still tried as an image (a raw 50 MP phone photo is
|
||
/// ~25 MB). Anything bigger is kept as a file: decoding it would need several
|
||
/// times its size in memory.
|
||
const int kMaxImageDecodeBytes = 64 * 1024 * 1024;
|
||
|
||
/// How many leading bytes [looksLikeImage] needs.
|
||
const kImageSniffBytes = 12;
|
||
|
||
/// Whether [head] starts like a photo or screenshot format: JPEG, PNG, GIF,
|
||
/// WebP, BMP or TIFF.
|
||
///
|
||
/// Only these are re-encoded. `package:image` also decodes PSD, TGA, EXR and
|
||
/// ICO, but flattening those to a JPEG would destroy what they were attached
|
||
/// for, so they stay files.
|
||
bool looksLikeImage(List<int> head) {
|
||
bool starts(List<int> magic, [int at = 0]) {
|
||
if (head.length < at + magic.length) return false;
|
||
for (var i = 0; i < magic.length; i++) {
|
||
if (head[at + i] != magic[i]) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
const riff = [0x52, 0x49, 0x46, 0x46];
|
||
const webp = [0x57, 0x45, 0x42, 0x50];
|
||
return starts(const [0xFF, 0xD8, 0xFF]) ||
|
||
starts(const [0x89, 0x50, 0x4E, 0x47]) ||
|
||
starts(const [0x47, 0x49, 0x46, 0x38]) ||
|
||
(starts(riff) && starts(webp, 8)) ||
|
||
starts(const [0x42, 0x4D]) ||
|
||
starts(const [0x49, 0x49, 0x2A, 0x00]) ||
|
||
starts(const [0x4D, 0x4D, 0x00, 0x2A]);
|
||
}
|
||
|
||
/// Re-encodes [bytes] as a metadata-free JPEG no larger than [kMaxImageSide]
|
||
/// on its long side, or returns null when [bytes] is not a decodable image.
|
||
Uint8List? prepareImage(Uint8List bytes) {
|
||
final decoded = _decode(bytes);
|
||
if (decoded == null) return null;
|
||
var image = img.bakeOrientation(decoded);
|
||
final longSide = math.max(image.width, image.height);
|
||
if (longSide > kMaxImageSide) {
|
||
image = image.width >= image.height
|
||
? img.copyResize(image, width: kMaxImageSide)
|
||
: img.copyResize(image, height: kMaxImageSide);
|
||
}
|
||
if (image.hasAlpha) image = _flattenOntoWhite(image);
|
||
image.exif = img.ExifData();
|
||
return img.encodeJpg(image, quality: kJpegQuality);
|
||
}
|
||
|
||
/// [prepareImage] off the calling isolate: decoding a 12 MP photo takes long
|
||
/// enough to drop frames if it runs on the UI isolate.
|
||
Future<Uint8List?> prepareImageInBackground(Uint8List bytes) =>
|
||
Isolate.run(() => prepareImage(bytes));
|
||
|
||
/// `decodeImage` returns null for most non-images but *throws* (a
|
||
/// RangeError from its PSD sniffer, seen on a 3-byte input) for others; both
|
||
/// mean "not an image" here.
|
||
img.Image? _decode(Uint8List bytes) {
|
||
try {
|
||
return img.decodeImage(bytes);
|
||
} on Object {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// JPEG has no alpha: without this a transparent screenshot's clear pixels
|
||
/// come out as whatever colour their RGB happened to hold, usually black.
|
||
img.Image _flattenOntoWhite(img.Image image) {
|
||
final background = img.Image(width: image.width, height: image.height)
|
||
..clear(img.ColorRgb8(255, 255, 255));
|
||
return img.compositeImage(background, image);
|
||
}
|