todo-app/lib/attachments/image_store_api.dart
Krzysztof kuhy Rudnicki 9f2ffdb8a9
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Attach any file to a note, of any size, not only images
📎 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>
2026-10-01 12:17:06 +02:00

134 lines
4.4 KiB
Dart

/// What the UI asks of attachments (images and any other file),
/// independent of platform.
///
/// Android keeps attachments itself (an `ImageCacheStore` in app storage,
/// talking to dufs directly); the desktop web page delegates everything to
/// the local wrapper's `/images/*` routes. Both implement [ImageStore],
/// picked by the conditional export in `image_store.dart`.
library;
import 'package:file_selector/file_selector.dart' show XFile;
import 'package:flutter/foundation.dart';
import 'package:todo/images/attachment_links.dart';
import 'package:todo/images/attachment_ref.dart';
export 'package:file_selector/file_selector.dart' show XFile;
/// Where to draw an image from right now.
sealed class ImageSource {
const ImageSource();
}
/// A file on this device.
final class LocalImageSource extends ImageSource {
/// Creates a source for the file at [path].
const LocalImageSource(this.path);
/// Absolute path of the cached JPEG.
final String path;
}
/// A same-origin URL served by the desktop wrapper.
final class NetworkImageSource extends ImageSource {
/// Creates a source for [url].
const NetworkImageSource(this.url);
/// The wrapper URL, e.g. `http://localhost:8730/images/<noteId>/<uuid>.jpg`.
final String url;
}
/// Not on this device yet (it downloads on the next reconcile).
final class MissingImageSource extends ImageSource {
/// The one instance.
const MissingImageSource();
}
/// Summary for the settings screen.
class ImageStoreStatus {
/// Creates a status.
const ImageStoreStatus({required this.configured, required this.pending});
/// Whether a dufs login is available to this device.
final bool configured;
/// Images waiting to upload.
final int pending;
}
/// Thrown by [ImageStore.attach] when the image could not be stored at all.
class ImageStoreException implements Exception {
/// Creates an exception carrying [message].
const ImageStoreException(this.message);
/// What went wrong, shown to the user.
final String message;
@override
String toString() => message;
}
/// Stores, shows and syncs the attachments notes link to. Notifies when one
/// becomes available or changes state, so embeds can redraw.
abstract class ImageStore extends ChangeNotifier {
/// Stores [bytes] (any image format) for [noteId] and queues the upload.
///
/// Returns the new ref, or null when [bytes] is not an image. Throws
/// [ImageStoreException] when it cannot be stored.
Future<AttachmentRef?> attach(String noteId, Uint8List bytes);
/// Stores the picked or dropped [file] for [noteId] — re-encoded when it is
/// an image, byte-for-byte otherwise, whatever its size — and queues the
/// upload. Throws [ImageStoreException] when it cannot be stored.
Future<Attachment> attachFile(String noteId, XFile file);
/// Uploads the queue, fetches every attachment [noteTexts] link to that
/// this device lacks, and evicts cached ones no note links to.
Future<void> reconcile(Iterable<String> noteTexts);
/// Where to draw [ref] from.
ImageSource sourceFor(AttachmentRef ref);
/// Whether [ref] is still waiting to upload (always false when unknown).
bool isPending(AttachmentRef ref);
/// Size in bytes of [ref] on this device, or null when it is not here yet.
Future<int?> sizeOf(AttachmentRef ref);
/// Login and queue summary.
Future<ImageStoreStatus> status();
}
/// Every attachment ref across [noteTexts].
Set<AttachmentRef> attachmentRefsAcross(Iterable<String> noteTexts) => {
for (final text in noteTexts) ...attachmentRefsIn(text),
};
/// The store used where none was provided (tests, and any screen built
/// outside the app root): attaching is refused, nothing is ever available.
class NoImageStore extends ImageStore {
@override
Future<AttachmentRef?> attach(String noteId, Uint8List bytes) async =>
throw const ImageStoreException('Images are not available here');
@override
Future<Attachment> attachFile(String noteId, XFile file) async =>
throw const ImageStoreException('Attachments are not available here');
@override
Future<void> reconcile(Iterable<String> noteTexts) async {}
@override
ImageSource sourceFor(AttachmentRef ref) => const MissingImageSource();
@override
bool isPending(AttachmentRef ref) => false;
@override
Future<int?> sizeOf(AttachmentRef ref) async => null;
@override
Future<ImageStoreStatus> status() async =>
const ImageStoreStatus(configured: false, pending: 0);
}