file_saver_ffi 0.12.0
file_saver_ffi: ^0.12.0 copied to clipboard
Save bytes, files and downloads to the gallery, device storage or a user-picked folder, with progress, cancellation and conflict handling. Native FFI and JNI on mobile.
File Saver FFI #
A high-performance file saver for Flutter supporting Android, iOS, macOS, Windows, Linux, and Web. Save to gallery, device storage, or a user-chosen directory â with real-time progress tracking.
Features #
- đŧī¸ Gallery Saving â Save images and videos to iOS Photos or Android Gallery with custom albums
- ⥠Native Performance â Swift through FFI on iOS/macOS, Kotlin through JNI on Android, and
dart:ioon Windows/Linux, with no method channel in between - đ Web Support â Browser download via anchor element or File System Access API (FSA) for Chrome/Edge
- đ Universal Storage â Save any file type (PDF, ZIP, DOCX, etc.) to device storage
- đž Original Quality â Files saved bit-for-bit without compression or metadata loss
- đ Progress & Cancellation â Real-time progress tracking with cancellable operations
- âī¸ Conflict Resolution â Auto-rename, overwrite, skip, or fail on existing files
- đ Remembered Directories â Keep access to a user-picked directory across app restarts (Android, iOS, macOS, Windows, Linux)
If you want to say thank you, star us on GitHub or like us on pub.dev.
đ¤ Ask AI About This Library #
Have questions about file_saver_ffi? Get instant AI-powered answers about the library's features, usage, and best
practices.
â Chat with AI Documentation Assistant
Ask anything like:
- "How do I save a video to the gallery with progress tracking?"
- "What's the difference between save and saveAs?"
- "How to handle permission errors on Android 10+?"
- "Show me examples of custom file types"
Installation #
First, follow the package installation instructions and add
file_saver_ffi to your app.
Quick Start #
Platform Setup #
Android Configuration
Supported: API 24+ (Android 7.0+). Apps on Android Gradle Plugin 8 or 9 build it, with AGP 9's built-in Kotlin on or off. With built-in Kotlin off (and on AGP 8), Flutter compiles the plugin's Kotlin with the Kotlin Gradle Plugin the app declares in android/settings.gradle(.kts), as flutter create sets it up.
For Standard Save Locations (Downloads, Pictures, etc.), no configuration is needed. The plugin automatically:
- Declares
WRITE_EXTERNAL_STORAGEpermission (merged via manifest merger, only applies to API ⤠28) - Requests runtime permission when needed (Android 9 and below)
- Uses scoped storage on Android 10+ (no permission required)
Custom Paths (PathLocation) #
Saving to custom paths on Android 11+ requires the MANAGE_EXTERNAL_STORAGE permission.
Add to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
Important: You must also request this permission at runtime using a package like
permission_handlerbefore calling anyFileSaverAPI with aPathLocationorfile://URI on Android 11+.
Opening file:// URIs (PathLocation) #
If you save to a filesystem directory path (e.g. PathLocation(...)), the returned URI can be file://....
On Android, opening file:// URIs requires a FileProvider (Android N+ blocks sharing raw file:// URIs).
Only add this if you call FileSaver.openFile(uri) with file://... URIs.
Add to android/app/src/main/AndroidManifest.xml:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.file_saver_ffi.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_saver_ffi_file_paths" />
</provider>
Create android/app/src/main/res/xml/file_saver_ffi_file_paths.xml:
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="files" path="." />
<cache-path name="cache" path="." />
<external-files-path name="external_files" path="." />
<external-cache-path name="external_cache" path="." />
<!-- Include this if you save to public external storage paths -->
<external-path name="external" path="." />
</paths>
iOS Configuration
Supported: iOS 13.4+ (Swift Package Manager and CocoaPods)
Add to ios/Runner/Info.plist:
<!-- For Photos Library Access (images/videos) -->
<key>NSPhotoLibraryAddUsageDescription</key>
<string>This app needs permission to save photos and videos to your library</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs permission to access your photo library</string>
<!-- Prevent automatic "Select More Photos" prompt on iOS 14+ -->
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
<!-- Optional: Make files visible in Files app -->
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
MacOS Configuration
Supported: macOS 10.15.4+ (Swift Package Manager and CocoaPods)
Add to macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements:
<!-- Required for network downloads -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Required for directory picker (NSOpenPanel) -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- Add entitlements for each MacosSaveLocation you use -->
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.assets.pictures.read-write</key>
<true/>
<key>com.apple.security.assets.movies.read-write</key>
<true/>
<key>com.apple.security.assets.music.read-write</key>
<true/>
Note: Each
MacosSaveLocationrequires its corresponding entitlement in sandboxed apps. OnlyMacosSaveLocation.documents(App Container) works without any entitlement. UsepickDirectory()to let users choose directories outside the sandbox.
Windows Configuration
Supported: Windows 10+
No configuration needed. Files are saved directly to Windows Known Folders (Downloads, Pictures, Videos, Music, Documents).
Linux Configuration
Supported: Ubuntu 20.04+ and other modern Linux distributions
Enable Linux desktop support on your development machine:
flutter config --enable-linux-desktop
Install required system packages (if not already present):
sudo apt-get install -y \
clang cmake ninja-build \
libgtk-3-dev pkg-config \
libblkid-dev liblzma-dev
No additional app configuration needed. Directories are resolved via the XDG Base Directory Specification (xdg-user-dirs).
Web Configuration
Supported: All modern browsers (Chrome / Edge 86+ recommended)
Browser capabilities:
| Feature | Chrome / Edge 86+ | Firefox | Safari |
|---|---|---|---|
save / saveNetwork |
â | â | â |
saveAs (directory picker) |
â File System Access API | â falls back to browser download | â falls back to browser download |
| Download progress | â
saveAs + SaveNetworkInput, and saveNetwork with headers |
â | â |
Download mechanism:
| API | Scenario | Mechanism | CORS required | Progress |
|---|---|---|---|---|
saveNetwork |
No custom headers |
<a href=url download> â browser native streaming |
â | â |
saveNetwork |
With custom headers |
window.fetch() â Blob â <a download> |
â | â
(fraction needs Content-Length) |
saveAs |
Chrome / Edge 86+ | FSA: streams chunks directly to disk (zero RAM) | â | â
(fraction needs Content-Length) |
saveAs |
Firefox / Safari | Falls back to standard browser download | â | â |
Note:
saveAswith directory selection requires the app to be served over HTTPS orlocalhost.http://origins cannot useshowDirectoryPicker.
See Web â Limitations for CORS, memory usage, and browser-specific constraints.
Basic Usage #
import 'package:file_saver_ffi/file_saver_ffi.dart';
try {
final uri = await FileSaver.saveAsync(
input: SaveInput.bytes(imageBytes),
fileName: 'my_image',
fileType: ImageType.jpg,
);
print('Saved to: $uri');
} on PermissionDeniedException catch (e) {
print('Permission denied: ${e.message}');
} on FileSaverException catch (e) {
print('Save failed: ${e.message}');
}
Core Concepts #
Unified API #
The library provides a single, consistent API for all save operations using SaveInput polymorphism:
save: Stream-based save to standard location (Downloads, Photos, etc.).saveAsync: Future-based save to standard location.saveAs: Stream-based save to a user-selected location (System Picker).saveAsAsync: Future-based save to a user-selected location.
Input Sources (SaveInput)
Use the appropriate input class for your data source:
| Input Type | Data Source | Use Case |
|---|---|---|
SaveBytesInput |
Uint8List |
Small files in memory (images, generated PDFs) |
SaveFileInput |
String (path) |
Large files from disk (videos, recordings) |
SaveNetworkInput |
String (URL) |
Download and save directly from internet |
Web:
SaveFileInputis not supported and throwsInvalidInputException. UseSaveBytesInputorSaveNetworkInputinstead.
Usage Matrix đ
| Standard Location (Downloads, Photos, etc.) |
User-Chosen Location (System Picker) |
|
|---|---|---|
| Advanced Control (Stream, Cancel) |
save() |
saveAs() |
| Simple / Await (Future) |
saveAsync() |
saveAsAsync() |
Standard Location: defined enum (e.g.,
Downloads,Photos). User-Chosen: viapickDirectory()or auto-prompt.
File Types #
The library supports 35+ file formats across 4 categories:
| Category | Formats | Count | Example Types |
|---|---|---|---|
| Images | PNG, JPG, GIF, WebP, BMP, HEIC, HEIF, TIFF, ICO, DNG | 12 | ImageType.png, ImageType.jpg |
| Videos | MP4, MOV, MKV, WebM, AVI, 3GP, M4V, FLV, WMV, HEVC | 12 | VideoType.mp4, VideoType.mov |
| Audio | MP3, AAC, WAV, FLAC, OGG, M4A, AMR, Opus, AIFF, CAF | 11 | AudioType.mp3, AudioType.aac |
| Custom | Any format via extension + MIME type | â | CustomFileType(ext: 'pdf', mimeType: 'application/pdf') |
Save Locations #
Control where files are saved using platform-specific enum values:
Platform Comparison
| Value | Android (AndroidSaveLocation) |
iOS (IosSaveLocation) |
macOS (MacosSaveLocation) |
Windows (WindowsSaveLocation) |
Linux (LinuxSaveLocation) |
|---|---|---|---|---|---|
.downloads |
Downloads/ (default) | - | Downloads/ (default) | Downloads/ (default) | ~/Downloads/ (default) |
.pictures |
Pictures/ | - | Pictures/ | Pictures/ | ~/Pictures/ |
.movies |
Movies/ | - | Movies/ | - | - |
.videos |
- | - | - | Videos/ | ~/Videos/ |
.music |
Music/ | - | Music/ | Music/ | ~/Music/ |
.dcim |
DCIM/ | - | - | - | - |
.documents |
- | Documents/ (default) | Documents/ | Documents/ | ~/Documents/ |
.photos |
- | Photos Library | - | - | - |
Android 10+ file types per location: Android decides which file types a directory accepts. Images go to Pictures/ or DCIM/, videos to Movies/ or DCIM/ (Pictures/ too on newer versions: Android 16 accepts it, Android 10 doesn't), audio to Music/, and any type to Downloads/. Other combinations, such as a PDF in Pictures/, fail with
InvalidInputException. Android 9 and below accept any type anywhere. AsubDircontaining..is rejected.
Custom Paths (
PathLocation): You can also passPathLocation('/your/custom/path')anywhere aSaveLocationis expected, to save files directly to specific filesystem directory paths across all platforms (except Web). Note: On iOS,PathLocationonly supports the app's document sandbox due to iOS system limitations.
Web: Platform-specific
SaveLocationenums are not available. ThesaveLocationparameter insave/saveAsyncis ignored â the browser controls the download destination (usually the Downloads folder). To save to a specific directory, usesaveAswithpickDirectory()(requires Chrome / Edge 86+).
Conflict Resolution #
Handle existing files with 4 strategies:
| Strategy | Behavior | Use Case |
|---|---|---|
autoRename (default) |
Appends (1), (2), etc. to filename | Safe, prevents data loss |
overwrite |
Replaces existing file* | Update existing files |
fail |
Throws FileExistsException |
Strict validation |
skip |
Returns existing file URI | Idempotent saves |
* Platform limitations:
- iOS Photos: Can only overwrite files owned by your app
- Android 10+: Can only overwrite files owned by your app (scoped storage)
Common Use Cases #
Download video from Network to Gallery #
final uri = await FileSaver.saveAsync(
input: SaveNetworkInput(
url: 'https://example.com/video.mp4',
headers: {'Authorization': 'Bearer token'}, // Optional headers
timeout: Duration(minutes: 1), // Custom timeout
),
fileName: 'downloaded_video',
fileType: VideoType.mp4,
saveLocation: switch (defaultTargetPlatform) {
TargetPlatform.android => AndroidSaveLocation.movies,
TargetPlatform.iOS => IosSaveLocation.photos,
TargetPlatform.macOS => MacosSaveLocation.downloads,
TargetPlatform.windows => WindowsSaveLocation.downloads,
TargetPlatform.linux => LinuxSaveLocation.downloads,
_ => null,
},
);
Progress Tracking #
// Using Unified API
await FileSaver.saveAsync(
input: SaveNetworkInput(url: '...'),
fileName: 'video',
fileType: VideoType.mp4,
onProgress: (progress) {
print('Download progress: ${(progress * 100).toInt()}%');
},
);
// Using the stream, which also gives the bytes written. A download without
// Content-Length (or a compressed one) has an unknown size: totalBytes is
// null and progress stays 0.0.
FileSaver.save(
input: SaveNetworkInput(url: '...'),
fileName: 'video',
fileType: VideoType.mp4,
).listen((event) {
if (event case SaveProgressUpdate(:final bytesWritten, :final totalBytes)) {
print(totalBytes == null
? '$bytesWritten bytes'
: '$bytesWritten of $totalBytes bytes');
}
});
Cancellation #
// Stream API allows cancellation
final subscription = FileSaver.save(
input: SaveNetworkInput(url: '...'), // Works for all inputs
fileName: 'video',
fileType: VideoType.mp4,
).listen((event) => print(event));
// Cancel anytime. Completes once the partial file is deleted.
await subscription.cancel();
A cancelled stream emits no further events, so no SaveProgressCancelled or CancelledException follows. Leaving an await for loop early cancels the save the same way.
Save to User-Selected Directory #
// 1. Pick directory (Optional, saveAs handles this automatically if null)
final location = await FileSaver.pickDirectory();
if (location != null) {
// 2. Save file to that directory
await FileSaver.saveAsAsync(
input: SaveBytesInput(pdfBytes),
fileName: 'invoice',
fileType: CustomFileType(ext: 'pdf', mimeType: 'application/pdf'),
saveLocation: location,
);
}
To use the same directory after the app restarts, store a token from persistDirectory and restore it on the next launch. On Android the token keeps a persistable URI permission, on iOS and macOS it holds bookmark data, and on web it refers to the directory handle stored in IndexedDB. On web a restored directory may need requestAccess() from a button first; see WEB_LIMITATIONS.md.
// After picking:
prefs.setString('exportDir', await FileSaver.persistDirectory(location));
// On a later launch:
final token = prefs.getString('exportDir');
final restored = token == null ? null : await FileSaver.restoreDirectory(token);
if (restored == null) {
// Access is gone (directory deleted, permission revoked): pick again.
} else {
// A bookmark goes stale when the directory moves, so store a fresh token.
prefs.setString('exportDir', await FileSaver.persistDirectory(restored));
}
// When the directory is no longer needed:
await FileSaver.releaseDirectory(prefs.getString('exportDir')!);
await prefs.remove('exportDir');
API Reference #
Unified API #
save
Stream-based API for advanced control (cancellation, detailed events).
Stream<SaveProgress> save({
required SaveInput input,
required String fileName,
required FileType fileType,
// ... same optional params
})
saveAsync
Future-based API for simple usage.
Future<Uri> saveAsync({
required SaveInput input,
required String fileName,
required FileType fileType,
SaveLocation? saveLocation,
String? subDir,
ConflictResolution conflictResolution,
Function(double)? onProgress,
})
saveAs
Stream-based interactive save.
Stream<SaveProgress> saveAs({
required SaveInput input,
required String fileName,
required FileType fileType,
PickedDirectoryLocation? saveLocation,
ConflictResolution conflictResolution,
})
saveAsAsync
Interactive save (shows picker) or save to specific PickedDirectoryLocation. Returns null when the picker is dismissed. Invalid input, such as empty bytes, throws InvalidInputException before the picker opens.
Future<Uri?> saveAsAsync({
required SaveInput input,
required String fileName,
required FileType fileType,
PickedDirectoryLocation? saveLocation,
ConflictResolution conflictResolution,
Function(double)? onProgress,
})
openWrite
Opens a file for streaming writes, so the data can be written chunk by chunk. Returns a FileSaverSink, or null when conflictResolution is skip and the file exists.
Future<FileSaverSink?> openWrite({
required String fileName,
required FileType fileType,
SaveLocation? saveLocation,
String? subDir,
int? totalSize,
ConflictResolution conflictResolution,
})
close() finishes the file and returns its URI. cancel() deletes the partial file. If a write fails, close() throws that error and the partial file is deleted.
final sink = await FileSaver.openWrite(
fileName: 'recording',
fileType: VideoType.mp4,
totalSize: expectedBytes,
);
if (sink == null) return;
sink.progress.listen((p) => print('${(p * 100).round()}%'));
await sink.addStream(chunks); // waits for each chunk to be written
final uri = await sink.close();
openWriteAs
Streaming writes to a user-selected directory (the system picker is shown when saveLocation is null). Returns a FileSaverSink, or null when the picker is cancelled or skip finds an existing file.
Future<FileSaverSink?> openWriteAs({
required String fileName,
required FileType fileType,
PickedDirectoryLocation? saveLocation,
int? totalSize,
ConflictResolution conflictResolution,
})
pickDirectory
Open system picker to let user choose a folder. shouldPersist (Android only) is deprecated and will be removed in 1.0.0: call persistDirectory on the result instead.
Future<PickedDirectoryLocation?> pickDirectory({bool shouldPersist = true})
persistDirectory / restoreDirectory / releaseDirectory
Keep access to a picked directory across app restarts.
static Future<String> persistDirectory(PickedDirectoryLocation location)
static Future<PickedDirectoryLocation?> restoreDirectory(String token)
static Future<void> releaseDirectory(String token)
canOpenFile
Checks whether the file at uri is accessible for reading.
static Future<bool> canOpenFile(Uri uri)
openFile
Opens a saved file with the appropriate system app.
static Future<void> openFile(Uri uri, {String? mimeType})
Input Models #
SaveBytesInput
fileBytes:Uint8List(Required)
SaveFileInput
filePath:String(Required - absolute path)
SaveNetworkInput
url:String(Required)headers:Map<String, String>?(Optional)timeout:Duration(Default: 60s) â enforced on Web viaAbortControllerwhen customheadersare used; ignored for header-less requests (anchor element)
SaveProgress Events #
Stream API emits these sealed class events. A save stream is single-subscription: the save starts when it is listened to, and a second listen throws a StateError instead of saving the file again. Call save again for another save, or use asBroadcast() to share one save between listeners.
| Event | Properties | Description |
|---|---|---|
SaveProgressStarted |
- | Operation began |
SaveProgressUpdate |
progress: double, bytesWritten: int, totalBytes: int? |
Progress from 0.0 to 1.0, and the bytes written of the file's size (null if unknown) |
SaveProgressComplete |
uri: Uri |
Success with saved file URI |
SaveProgressError |
exception: FileSaverException |
Error occurred |
SaveProgressCancelled |
- | saveAs picker dismissed |
Exception Reference #
| Exception | Description | Error Code |
|---|---|---|
PermissionDeniedException |
Storage access denied | PERMISSION_DENIED |
FileExistsException |
File exists with fail strategy |
FILE_EXISTS |
StorageFullException |
Insufficient device storage | STORAGE_FULL |
InvalidInputException |
Empty bytes or invalid input | INVALID_INPUT |
FileIOException |
File system error | FILE_IO_ERROR |
UnsupportedFormatException |
Format not supported on platform | UNSUPPORTED_FORMAT |
SourceFileNotFoundException |
Source file not found (saveFile) | FILE_NOT_FOUND |
ICloudDownloadException |
iCloud download failed (iOS) | ICLOUD_DOWNLOAD_FAILED |
NetworkException |
Download failed; statusCode is set for an HTTP error |
NETWORK_ERROR |
OpenFailedException |
openFile found no app or failed |
OPEN_FAILED |
CancelledException |
close() of a cancelled FileSaverSink |
CANCELLED |
NativePlatformException |
Generic platform error, or a dir_picker error with its code |
PLATFORM_ERROR |
FileSaverErrorCode has a constant for each code, plus INCOMPLETE, which saveAsync and saveAsAsync throw as a NativePlatformException when the stream ends without a result.
Contributing #
Contributions are welcome! Please feel free to submit a Pull Request.
FAQ #
âšī¸ iOS Photos Permissions & Albums
When saving to IosSaveLocation.photos, the permission requested depends on the subDir:
subDir |
Permission | Dialog (iOS 14+) | Capabilities |
|---|---|---|---|
"MyAlbum" |
.readWrite |
Full / Limited / Deny | Album creation, conflict resolution |
null |
.addOnly |
Allow / Deny | Basic save only (no album) |
If the app already has .readWrite access (full or limited), a save without subDir uses it too, so conflict resolution works and canOpenFile/openFile accept the returned ph:// URI. With .addOnly access, existing assets can't be looked up: every save adds a new asset, and the URI can't be opened.
Important: If
.readWriteis denied, the save fails. There is no automatic fallback to.addOnly.
Only images and videos can be saved to IosSaveLocation.photos. Audio and other files throw InvalidInputException with every API; save them to IosSaveLocation.documents.
â Why are files not saving to Downloads, Pictures, Music (macOS)?
App Sandbox restricts access to user folders by default. If your app is sandboxed (which is typical for macOS Store apps), you must add specific entitlements to your .entitlements files.
Solution:
Add the required keys (e.g., com.apple.security.files.downloads.read-write) to your macos/Runner/*.entitlements files.
Check the MacOS Configuration section above for the full list of required keys.
License #
MIT License - see LICENSE file for details.