device_io 1.0.0 copy "device_io: ^1.0.0" to clipboard
device_io: ^1.0.0 copied to clipboard

Cross-platform file picking, saving, sharing, and opening for Flutter. Image and file pickers, save dialogs, OS share sheet, open-with — one API on mobile, desktop, and web.

device_io — cross-platform device IO for Flutter

pub package likes pub points GitHub stars license: MIT

Pick images and files, share through the OS sheet, save to the device, open in the default viewer. One API on iOS, Android, macOS, Windows, Linux, and web.

Every call returns a typed result instead of throwing. A cancelled picker, an unsupported platform, and a real failure are three distinct values: not one exception to decode, and no kIsWeb in your app code.

like it? a ⭐ star or 👍 like is the entire marketing budget. Bugs & features →


👀 Peek inside

Install #

Add the dependency #

dependencies:
  device_io: ^1.0.0

Then call DeviceIO() once at startup and keep the result (see Quick start). The underlying plugins are federated, so there's no per-platform Dart to wire up.

Each platform does need its permission and entitlement setup below — the OS requires it, and no package can add it.

iOS #

Add the usage descriptions your app triggers to ios/Runner/Info.plist. Write your own copy — the OS shows these strings in the permission prompt:

<key>NSPhotoLibraryUsageDescription</key>
<string>Lets you pick photos to attach and share.</string>
<key>NSCameraUsageDescription</key>
<string>Lets you capture a photo or video from the camera.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Used while recording video from the camera.</string>

Skip the keys for features you don't use: no camera capture, no NSCameraUsageDescription.

Set your iOS deployment target to 14.0; file_picker 12's Swift Package requires it.

🧩 where to set the iOS 14 floor

Set platform :ios, '14.0' in ios/Podfile, and IPHONEOS_DEPLOYMENT_TARGET = 14.0 on the Runner target in Xcode. Older scaffolds default to 13.0 and fail the build with requires minimum platform version 14.0. The example app has both set if you'd rather copy from a working one.

Android #

No permissions, no manifest entries. Picking goes through the Android photo picker and the Storage Access Framework; saveAs writes through the system create-document dialog. None needs a runtime permission or a <uses-permission> line.

The only requirement is recent build tools: AGP 8.12.1 · Gradle 8.13 · Kotlin 2.2.0 (via share_plus 13). Current Flutter scaffolds already ship newer, so most apps do nothing; only an old one has to bump.

🧩 the exact Android tooling floors

share_plus 13 is the binding one: file_picker 12 only needs AGP 8.5.2, under that. device_io's own example is CI-built on AGP 9 / Gradle 9 / Kotlin 2.3, what current Flutter scaffolds ship. Older apps: bump those in settings.gradle and the Gradle wrapper before the first build.

macOS #

The App Sandbox gates file access behind entitlements. Add these to both macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements:

<!-- saveAs + file picking — locations the user chooses in a dialog -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- silent save into the real Downloads folder -->
<key>com.apple.security.files.downloads.read-write</key>
<true/>

Skip the Downloads entitlement if you only use saveAs — a silent save returns PlatformFailed without it.

Linux, Windows, Web #

Nothing to add. They work as soon as the dependency resolves.


Quick start #

Construct once, then reach for the four capabilities off it. Here's pick-an-image-then-share-it — the whole round trip:

import 'package:device_io/device_io.dart';

// once, at app startup — keep the instance around
final deviceIO = DeviceIO(
  config: DeviceIOConfig(downloadsSubfolder: 'MyApp'),
);

// pick, then share the bytes
final picked = await deviceIO.picker.pickImage();
if (picked case PlatformSuccess(value: final asset)) {
  await deviceIO.sharer.shareFile(
    bytes: await asset.readBytes(),
    fileName: asset.fileName ?? 'photo.png',
    text: 'Look at this!',
  );
}

That's the shape of every call: ask a capability, get a PlatformResult, match the outcome you care about. pickImage, shareFile, save, openBytes — same shape, a different verb.

The if (... case ...) above handles just the happy path. To react to every outcome, switch over the sealed result and let the compiler check each arm (see Results).


Results #

Every method returns a PlatformResult<T>. Five outcomes, each a distinct value:

Variant Means Typical handling
PlatformSuccess(value) It worked; value is the payload Use it
PlatformCancelled() User dismissed the picker / sheet / dialog Do nothing
PlatformUnsupported(reason) Not available on this platform Hide the feature
PlatformPermissionDenied() OS blocked access (camera, photos, storage) Point the user at Settings
PlatformFailed(message, error, stackTrace) Supported, but failed at runtime Show / report the error

Consume it with an exhaustive switch — the compiler makes you handle every outcome:

switch (await deviceIO.picker.pickImage()) {
  case PlatformSuccess(:final value):
    await upload(await value.readBytes());
  case PlatformCancelled():
    break; // nothing to do
  case PlatformPermissionDenied():
    openAppSettings();
  case PlatformUnsupported(:final reason):
    hideFeature(reason);
  case PlatformFailed(:final message):
    showError(message);
}

PlatformPermissionDenied extends PlatformFailed, so a bare case PlatformFailed() catches it too. Put the specific arm before the generic one when denial deserves its own recovery; drop it and the failure arm handles everything.

The value inside PlatformSuccess is never null.

There are deliberately no isSupported / valueOrNull / when shortcuts. A sealed result read through an escape hatch stops being exhaustive — and that's the point: the compiler catches the outcome you forgot.

🧩 why a sealed result instead of just throwing?

Because two of the five outcomes aren't errors, and exceptions can't say so.

A user who opens the photo picker and taps Cancel did nothing wrong. A desktop with no camera isn't broken. If those threw, every call site would wrap a try/catch and then guess, from the exception type or its message, whether to show an error, stay quiet, or hide a button. A nullable return (Future<PickedAsset?>) is no better — null can't tell "cancelled" apart from "unsupported."

So the package spends a variant on each real outcome. PlatformCancelled and PlatformUnsupported are values, not failures; PlatformFailed is reserved for the "supported, but it broke" case, with PlatformPermissionDenied as a named subtype because its recovery differs (send the user to Settings, don't retry). The result is sealed, so the compiler flags any arm you forgot — you find out at build time, not from a crash report.

Throws are kept for one thing only: programmer error. Pass an empty list to shareFiles and it throws ArgumentError synchronously — that's a bug in the call, not a runtime state to branch on.


Usage #

Four doors off the container: picker, sharer, saver, opener. Pick the one that fits. Highlights below; every method and full signature lives in the API reference.

Pick #

deviceIO.picker covers images, video, mixed media, and generic files — single or multi.

final picker = deviceIO.picker;

// images
await picker.pickImage(options: ImageOptions(maxWidth: 1024, quality: 85));
await picker.pickImages(limit: 5);          // never returns an empty list
await picker.captureImage();                // camera; Unsupported on desktop

// video + mixed
await picker.pickVideo();
await picker.captureVideo(maxDuration: Duration(minutes: 2)); // camera
await picker.pickMedia();                   // one image OR video
await picker.pickMultipleMedia(limit: 10);  // images and/or videos

// generic files, optionally filtered by extension
await picker.pickFile(allowedExtensions: ['mp3', 'wav']);
await picker.pickFiles();

A few notes:

  • The multi-picks never surface an empty selection — that's PlatformCancelled, so you never branch on list.isEmpty.
  • limit caps how many the user can pick where the platform supports it, and is ignored elsewhere.
  • ImageOptions (maxWidth, maxHeight, quality) resizes and recompresses images. A picked video comes back untouched.

Hide the camera button when it isn't there:

if (picker.isCameraSupported) showCameraButton();

Camera capture runs on phones and tablets (native apps and mobile browsers). Desktop returns PlatformUnsupported.

For pickMedia, branch on the result's mimeType to tell an image from a video:

if (result case PlatformSuccess(:final value)) {
  final isVideo = value.mimeType.startsWith('video/');
}
🧩 what "lazy read" means for a picked file

A pick doesn't hand you bytes. It hands you a PickedAsset with two read callbacks, and nothing is read until you call one:

final small = await asset.readBytes();      // whole thing, one Uint8List
final chunks = asset.readStream();          // Stream<List<int>>, constant memory

Use readBytes() for avatars and thumbnails. Use readStream() for photos, videos, model files — anything you'd rather not hold in RAM. Pipe the stream straight into a save, no buffering:

await deviceIO.saver.saveStream(
  byteStream: asset.readStream(),
  fileName: asset.fileName ?? 'video.mp4',
);

Each readStream() call returns a fresh stream, so you can read the same asset more than once. There's no filePath and no eager bytes field on purpose — paths don't exist on web, and eager bytes would OOM the first large file. Where the read comes from:

  • Native picks read from disk on demand.
  • Web image picks read from the browser blob on demand.
  • Web generic-file picks read on demand too: via File System Access where the browser has it, otherwise through the file-picker plugin's own blob read. The one exception is a multi-file pick without File System Access (Firefox, Safari), which buffers the selection up front.

Share #

deviceIO.sharer opens the OS share sheet (the Web Share API on web). Text, one file, many files, or a stream. The adapter stages temp files.

final sharer = deviceIO.sharer;

await sharer.shareText(text: 'Check this out', subject: 'A link');

await sharer.shareFile(
  bytes: pngBytes,
  fileName: 'chart.png',
  text: 'This quarter',
);

// several files in one sheet
await sharer.shareFiles(
  files: [
    ShareFile(bytes: pngBytes, fileName: 'chart.png'),
    ShareFile(bytes: csvBytes, fileName: 'data.csv'),
  ],
);

// large file, without holding it in memory (native streams to disk;
// web buffers, because Web Share needs the whole file up front)
await sharer.shareFileStream(
  byteStream: asset.readStream(),
  fileName: 'recording.mp4',
);

Every share method takes an optional sharePositionOrigin — the anchor rectangle the iPadOS share popover points at. iPad needs it (hand over your button's global bounds); every other platform ignores it.

shareFiles throws ArgumentError on an empty list — a share sheet with nothing in it is a bug in the call, not a runtime state.

🧩 what happens to a shared or opened file afterward?

Sharing and opening bytes both need a real file on disk: the OS share sheet and the default viewer take a file, not a Uint8List. So the adapter stages one: it writes your bytes into a fresh temporary directory under the OS cache, with the real filename preserved (the share sheet shows chart.png, not a random hash).

That staged file is deliberately not deleted when the call returns. On Android the share Future resolves the moment the sheet closes, but the app you shared to reads the file after that, so deleting it eagerly would hand the receiver an empty file. The OS reclaims its cache directory on its own schedule — simpler than guessing when the receiver is done.

Filenames are sanitized before they touch the path (traversal sequences, Windows-reserved names, control characters), and every staging call gets its own directory, so two shares of photo.png never collide. None of this is anything you call — it's what shareFile / shareFiles / openBytes do.

Save #

deviceIO.saver has two doors: save writes silently, saveAs asks the user where.

final saver = deviceIO.saver;

// silent, no dialog
final result = await saver.save(bytes: csvBytes, fileName: 'export.csv');
if (result case PlatformSuccess(value: SavedAtPath(:final path))) {
  // native: a real path you can reopen. On web it's SavedByBrowser instead —
  // the browser owns the file, there is no path.
}

// user picks the destination via the system dialog
await saver.saveAs(bytes: pdfBytes, fileName: 'report.pdf', dialogTitle: 'Save report');

// stream a big file to disk chunk by chunk (constant memory on native)
await saver.saveStream(byteStream: asset.readStream(), fileName: 'video.mp4');

Before you rely on save on a phone: it writes to an app-private folder there (invisible in the Files app, gone on uninstall). For a save the user can find, use saveAs (system dialog, public storage, no permissions).

Full per-platform breakdown in Where saves land.

Three things the saver handles:

  • Never clobbers — a taken report.pdf becomes report (1).pdf, and unsafe characters in the name are sanitized out.
  • Atomic streams — a streaming save writes to a .part file, renamed only once the stream finishes; a failure leaves nothing behind.
  • mimeType — sets the blob content type on web; native infers it from the extension.

Open #

deviceIO.opener opens content in the platform's default viewer: Preview, Photos, a browser tab, whatever the OS ties to the type.

final opener = deviceIO.opener;

// works everywhere — native stages a temp file, web opens a blob in a new tab
await opener.openBytes(bytes: pdfBytes, fileName: 'doc.pdf');

// open a path you already have (native only) — pairs with save
final saved = await deviceIO.saver.save(bytes: bytes, fileName: 'report.pdf');
if (saved case PlatformSuccess(value: SavedAtPath(:final path))) {
  await opener.openPath(filePath: path);
}

openBytes is the cross-platform path — reach for it when you have bytes to put on screen.

openPath takes an absolute path and returns PlatformUnsupported on web, where filesystem paths don't exist. Feed the same bytes to openBytes there instead.


Error handling #

When something breaks you get a PlatformFailed carrying three things: a human-readable message for a snackbar, plus the original error and its stackTrace for logging or crash reporting.

final result = await deviceIO.saver.saveAs(bytes: bytes, fileName: 'report.pdf');
switch (result) {
  case PlatformSuccess(:final value):
    showSaved(value);
  case PlatformCancelled():
    break; // dismissed the dialog — nothing to report
  case PlatformPermissionDenied():
    promptForSettings();
  case PlatformFailed(:final message, :final error, :final stackTrace):
    logger.report(message, error, stackTrace);
}

message is a diagnostic string, not a localized one — your app translates for its users.

Permission denials arrive as PlatformPermissionDenied (a PlatformFailed subtype, see Results) because the recovery differs: send the user to system settings, don't retry. The package maps each plugin's exact permission code to that variant, so you never string-match error text.


Platform support #

One API, six targets. Each capability is backed by a federated plugin (or a web API):

Capability Android iOS macOS Windows Linux Web
Pick image_picker / file_picker image_picker / file_picker file_picker file_picker file_picker image_picker / File System Access
Share share_plus share_plus share_plus share_plus share_plus Web Share API
Save path_provider / SAF dialog path_provider / Files export path_provider / native dialog path_provider / native dialog path_provider / native dialog blob download / File System Access
Open open_filex open_filex OS open OS open OS open blob in new tab

Camera capture is available on phones and tablets (native apps and mobile browsers) and returns PlatformUnsupported on desktop.

🧩 how does one API stay honest across six platforms?

Where a platform can't do something, you get a typed PlatformUnsupported — never a silent no-op, never a faked success. openPath on web returns Unsupported because browsers have no filesystem paths; camera capture returns Unsupported on desktop. Your app never writes kIsWeb, and never gets a fake answer.

How the six-platform guarantee holds under the hood is in Architecture.

Where saves land #

The two save doors resolve differently per platform:

save (silent) saveAs (user picks)
Desktop Real Downloads folder Native save dialog
Android App-private dir (hidden from Files, gone on uninstall) Create-document dialog → public storage
iOS App sandbox Downloads (hidden from Files) Files export sheet
Web Browser download (browser decides location) File System Access dialog on Chromium; plain download elsewhere

On web, the Chromium save dialog comes from the File System Access API, writing straight to the file the user chose. Firefox and Safari fall back to a plain browser download.

Browser support #

There's no minimum-version table, because the web layer feature-detects at runtime and degrades gracefully instead of gating on a version:

  • File System Access (the saveAs dialog, lazy file-pick reads) is Chromium-only. Where it's absent (Firefox, Safari), saveAs becomes a plain download and a multi-file pick buffers up front (single picks still read lazily).
  • Web Share availability varies by browser and by whether the page is served over HTTPS. A dismissed sheet comes back as PlatformCancelled.
  • Camera capture works in mobile browsers (the file input's capture attribute); desktop browsers report PlatformUnsupported.

You never check any of this. The package picks the best path and hands you the same PlatformResult regardless.


Not in the box #

What the shipped package doesn't do, and what to reach for meanwhile. For the full per-capability status, see the capability roadmap.

  • Silent saves to public storage on mobile. save on a phone writes to app-private storage (see Save). Landing in public Downloads silently needs first-party MediaStore code, which this package skips (roadmap has the reasoning). saveAs is the answer: public storage, system dialog, no permissions. Need background exports? Open an issue.
  • Requesting permissions. This package surfaces denials as PlatformPermissionDenied; it doesn't pop the permission prompt or manage the flow. Apps own their permission UX and their Info.plist / manifest entries. For an explicit request-and-check flow, use permission_handler.
  • A viewer widget. openBytes and openPath open content in the OS default app — they don't draw it inside your UI. To render a PDF or image on screen, pair this with a viewer (pdfx for PDFs, a gallery widget for images): pick and save here, display there.
  • openPath on web. Filesystem paths don't exist in the browser, so openPath returns PlatformUnsupported there. openBytes is the web path — hand it the bytes and it opens a blob in a new tab.

Docs #

The README covers the everyday stuff. wanna go deeper?

Doc What's inside
Architecture How it's built: the four capabilities, the platform seam, lazy reads
Capabilities What's shipped, what's planned, what won't happen
Updating Maintenance recipes and pinned source-of-truth links
Contributing Setup, PR workflow, adding capabilities

License #

MIT. See LICENSE.

1
likes
150
points
251
downloads

Documentation

API reference

Publisher

verified publisherwhuppi.com

Weekly Downloads

Cross-platform file picking, saving, sharing, and opening for Flutter. Image and file pickers, save dialogs, OS share sheet, open-with — one API on mobile, desktop, and web.

Repository (GitHub)
View/report issues
Contributing

Topics

#file-picker #share #download #file-io #cross-platform

License

MIT (license)

Dependencies

file_picker, flutter, image_picker, meta, mime, open_filex, path_provider, share_plus, share_plus_platform_interface, web

More

Packages that depend on device_io