Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fox_utils
Reusable Flutter utilities shared across projects: permission-free Android file saving, a cross-platform clipboard wrapper, and desktop drag & drop.
Features
AndroidFiles.save — save files to public Android storage
Writes any bytes to a public Android directory (Pictures, Movies,
Music, Download) without any storage permission, with an optional
media scan so the file shows up in gallery apps.
final path = await AndroidFiles.save(
bytes, // any file type
fileName: 'image.jpg',
location: AndroidPublicLocation.pictures, // pictures | movies | music | downloads
subPath: 'Imagen', // folder structure under the location
scan: true, // false = saved but not indexed (no gallery)
);
Notes:
- Requires no permissions on Android 10+ (API 29+): creating new files inside media collections is permission-free. Legacy devices (< API 29) may fail — the exception carries the native message.
scan: falsewrites the file without indexing it. On modern Android the file can still be picked up by a later system rescan; for a hard "never in gallery" guarantee, save outside a media collection or add a.nomediafile to the folder.- Throws
FileSaveExceptionon failure (logged with stack trace).
ClipboardX — clipboard copy
await ClipboardX.copyImage(bytes); // image bytes as-is, MIME sniffed
await ClipboardX.copyFiles(['/path/a.png']); // desktop: files
ClipboardX.copyText('hello'); // text (Flutter built-in)
final text = await ClipboardX.readText();
final image = await ClipboardX.readImage();
final files = await ClipboardX.readFiles();
Platform matrix:
| Operation | Android | Windows | Linux | macOS | iOS |
|---|---|---|---|---|---|
| copy image | ✓ (FileProvider) | ✓ (GDI+) | ✓ (file reference) | ✓ | ✓ |
| copy files | – | ✓ (CF_HDROP) | ✓ (GNOME) | ✓ | – |
| copy/read text | ✓ | ✓ | ✓ | ✓ | ✓ |
| read image | ✓ | ✓ | ✓ | ✓ | ✓ |
| read files | ✓ (URIs) | ✓ | ✓ | ✓ | – |
Notes:
- Image bytes are placed as-is (no re-encode); the extension is sniffed from magic bytes (PNG/JPEG/GIF/WebP/BMP), so animated GIFs survive.
- Linux cannot hold raw image bytes on the clipboard (GTK limitation), so
copyImagestages the bytes to a rotating temp file and copies it as a file reference — pasted apps receive the image file. The staging file is replaced on each copy. - Android requires no manifest setup: the FileProvider is declared in
this plugin's manifest and merged automatically (
${applicationId}.provider). Images are staged in the app cache dir. - Throws
ClipboardCopyExceptionon failure.
ClipboardX.pasteImage — paste an image, whatever the source
final Uint8List? image = await ClipboardX.pasteImage();
if (image != null) {
// send to server, display, whatever
}
Resolution order: image data on the clipboard first (browser "copy image"),
then the first file reference (file manager "copy"). The file's first bytes
are sniffed (PNG/JPEG/GIF/WebP/BMP) before the full read, so huge non-image
files are never loaded. Returns null when the clipboard holds no image.
Source matrix:
| Copied from | Windows | Linux | Android |
|---|---|---|---|
| Browser, "copy image" | CF_DIB → image bytes | image/png → image bytes |
content URI (image mime) → image bytes |
| File manager, "copy" | CF_HDROP → first file's bytes | text/uri-list → first file's bytes |
content URI → resolved bytes |
Android notes:
- Reading the clipboard works only while the app is in the foreground
(Android 10+); background reads return
null. - Android 12+ shows a system toast ("app pasted from your clipboard") on read — cosmetic.
- File references come back as
content://URIs;pasteImageresolves them through the platform channel (readFileBytes), so no manifest setup or permission is needed.
DesktopDropX + DropTarget — drag & drop files and images
OS-level drag & drop into the app (desktop only). Wrap any widget in
DropTarget to get drag enter/exit/update/drop callbacks; DesktopDropX
turns the dropped items into plain paths or image bytes:
DropTarget(
onDragEntered: (d) => setState(() => dragging = true),
onDragExited: (d) => setState(() => dragging = false),
onDragDone: (details) async {
final paths = DesktopDropX.pathsOf(details.files); // absolute paths
final Uint8List? image = await DesktopDropX.readImage(details.files);
// image == null when the drop contains no readable image
},
child: myWidget,
)
Platform notes:
- Linux: GTK drag destination on the whole window (
text/uri-listfrom GNOME/KDE file managers, browsers, …). Items are absolute paths, delivered in the order dropped. - Windows: OLE
IDropTargeton the window (CF_HDROPfrom Explorer, browsers, …). Same path semantics;DropDoneDetails.localPositionis already DPI-corrected. - Android/iOS/web: no native handler — the widget renders its child and
no drop events ever arrive. macOS is not vendored (no macOS target);
re-adding it is a copy of upstream's
macos/folder behind the same API. DropItemextendsXFile:item.path,item.name,item.readAsBytes(),item.openRead(), … are available directly.- Drop targets receive events for the whole window; an invisible
DropTargetkeeps receiving them, so setenable: falsewhen pushing a page over it. Coordinate-basedDropTargetbounds filtering is handled by the widget. readImagenever throws: unreadable files are logged with a stack trace and skipped;nullmeans "no image in this drop".
media_kit hot-restart guard (Linux, debug)
Apps using media_kit crash on a hot restart (R) while any Player is
alive: mpv calls a wakeup callback that belonged to the discarded isolate.
fox_utils' Linux plugin clears those callbacks just before the engine
restarts, so hot restart works. Nothing to call; release builds compile it
out; it does nothing without media_kit. It reads media_kit's private
NativeReferenceHolder file (checked against media_kit 1.2.6): re-check after
media_kit upgrades.
Vendored engines
The clipboard channels (pasteboard method channel) are vendored from
MixinNetwork's pasteboard package (v0.5.0, Apache-2.0) because upstream's
build applies the legacy Kotlin Gradle Plugin (KGP), which future Flutter
versions will reject. The vendored copy lives in lib/vendor/ (Dart) and the
native platform folders, with these changes:
- Android: the Kotlin plugin was ported to Java and merged into
FoxUtilsPlugin(one plugin class, no KGP, no Kotlin in the module). - Byte-faithful image writes: upstream decodes and re-encodes images as PNG (destroying GIF animation and metadata); ours writes the original bytes with magic-byte MIME sniffing.
- Merged FileProvider: declared in the plugin manifest — zero per-project setup (upstream requires a manual manifest entry per app).
- Two upstream bugs fixed: clipboard image reads ran synchronously on the
calling thread (now on the executor), and
writeFilesdropped the first clip item (now assembles correctly). - Linux/Windows: C++ sources byte-identical, include paths and CMake
targets renamed to the
fox_utilspackage name.
To diff against upstream: git diff the folders against
packages/pasteboard/ at tag v0.5.0 of
https://github.com/MixinNetwork/flutter-plugins. If upstream ever
modernizes their build, un-vendoring is a swap behind the same ClipboardX
API.
The drag & drop channels (desktop_drop method channel) are vendored from
MixinNetwork's desktop_drop package (v0.7.1, Apache-2.0) for the same
reason: its android/ folder applies KGP. Only the Dart sources and the
Linux/Windows native folders are vendored — the Android/Kotlin, macOS and
web bindings are omitted (Android DnD is out of scope, and there is no macOS
target in the app). Linux/Windows native sources are byte-identical to
upstream except the include path rename (include/fox_utils/). Both engines
are registered from one native entry per platform:
fox_utils_plugin_register_with_registrar (Linux) /
FoxUtilsPluginRegisterWithRegistrar (Windows) — the plugin class in
pubspec.yaml is no longer PasteboardPlugin. To diff against upstream, use
tag desktop_drop-v0.7.1 of the same monorepo; un-vendoring is a swap
behind the same DesktopDropX API.
License
MIT for this package's own code; the vendored engines are Apache-2.0
© MixinNetwork (see lib/vendor/ and the native platform folders).