public_file_saver 1.2.1
public_file_saver: ^1.2.1 copied to clipboard
A reliable Flutter plugin for saving bytes, local files, and URL downloads to user-visible locations with direct-save and native-dialog support.
New API: use save() for bytes, local files, and URLs. The four legacy methods below are deprecated but remain available.
public_file_saver #
A reliable Flutter plugin for saving bytes, local files, and URL downloads to user-visible locations. It supports both direct saves and native save dialogs across Android, iOS, macOS, Web, Windows, Linux, and HarmonyOS (OHOS).
Features #
- ✅ Save binary data (Uint8List) to public directories
- ✅ Save with system file picker dialog
- ✅ Save local files
- ✅ Download and save files from URL
- ✅ Automatic file name sanitization
- ✅ MIME type inference
- ✅ Consistent return format across all platforms
- ✅ Swift Package Manager support on iOS
Platform Support #
| Feature | Android | iOS | macOS | Web | Windows | Linux | OHOS |
|---|---|---|---|---|---|---|---|
saveBytes() |
✅ | ✅ | ✅ | ✅¹ | ✅ | ✅ | ✅ |
saveBytesWithDialog() |
✅ | ✅ | ✅ | ✅¹ | ✅ | ✅ | ✅ |
saveFile() |
✅ | ✅ | ✅ | ❌² | ✅ | ✅ | ✅ |
saveFromUrl() |
✅ | ✅ | ✅ | ✅¹ | ✅ | ✅ | ✅ |
subDir parameter |
✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ |
fileSuffixChoices parameter |
❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
¹ On Web, saveBytesWithDialog() uses the native Save As picker in browsers that support the File System Access API (such as Chrome and Edge). Other browsers fall back to a normal download. The returned PublicSavedFile only carries fileName because browsers do not expose the final path.
² On Web, calling saveFile(File) throws UnsupportedError — dart:io's File is not available in the browser. Read the bytes yourself and call saveBytes() instead.
Installation #
Add this to your pubspec.yaml:
dependencies:
public_file_saver: ^1.2.0
Then run:
flutter pub get
Platform-Specific Setup #
Android #
The plugin declares the WRITE_EXTERNAL_STORAGE permission (limited to Android 9 and below via android:maxSdkVersion="28"), so it is merged into your app automatically.
- Android 10+ (API 29+): Uses MediaStore API, no permission needed
- Android 6.0–9: The plugin declares the permission, but your app must request it at runtime (e.g. with the
permission_handlerpackage) before callingsaveBytes() - Android 5.1 and below: Permission is granted automatically at install time
iOS #
Add the following keys to your Info.plist if you want users to access saved files via the Files app:
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
macOS #
If your app is sandboxed (the default for Mac App Store builds), enable user-selected file access in your entitlements to allow the save dialog to write outside the app sandbox:
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
Without the sandbox, no extra entitlements are needed. Direct (no-dialog) saveBytes writes to ~/Downloads; in a sandboxed build, the system redirects this to the per-app container's Downloads folder unless you add the Downloads folder entitlement to write to the real ~/Downloads:
<key>com.apple.security.files.downloads.read-write</key>
<true/>
Web #
No setup is required. saveBytes() triggers a standard browser download. saveBytesWithDialog() opens the native Save As picker when the File System Access API is available (secure contexts and localhost); unsupported browsers fall back to a standard download.
Windows #
No setup required. Direct mode uses FOLDERID_Downloads (the user's standard Downloads folder); dialog mode uses the native IFileSaveDialog.
Linux #
The plugin uses GTK 3, which is already linked by every Flutter Linux app, so no extra dependencies are required. Direct mode resolves the XDG Downloads directory (g_get_user_special_dir(G_USER_DIRECTORY_DOWNLOAD)), falling back to $HOME if XDG is not configured; dialog mode uses GtkFileChooserNative.
HarmonyOS (OHOS) #
The plugin uses DocumentViewPicker which requires no additional permissions.
Usage #
Import #
import 'package:public_file_saver/public_file_saver.dart';
Create Instance #
final fileSaver = PublicFileSaver();
API Reference #
saveBytes()
Save binary data directly to a public location without showing a dialog.
Future<PublicSavedFile?> saveBytes({
required Uint8List bytes,
required String fileName,
String mimeType = 'application/octet-stream',
String? subDir, // Android, iOS, macOS, Windows, Linux
})
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
bytes |
Uint8List |
Yes | The binary data to save |
fileName |
String |
Yes | Desired file name (will be sanitized) |
mimeType |
String |
No | MIME type of the file (default: application/octet-stream) |
subDir |
String? |
No | Subdirectory within the public directory (Android, iOS, macOS, Windows, Linux) |
Platform Behavior:
| Platform | Save Location | Returns |
|---|---|---|
| Android 10+ | MediaStore Downloads | uri: content:// URI |
| Android 9- | Public Downloads directory | path: full file path |
| iOS | App Documents directory (visible in Files app) | path: full file path |
| macOS | ~/Downloads (or sandbox-redirected) |
uri (file://) and path |
| Web | Browser download (browser-chosen) | fileName only |
| Windows | FOLDERID_Downloads |
path: full file path |
| Linux | XDG $HOME/Downloads |
uri (file://) and path |
| OHOS | User-selected via DocumentViewPicker | uri and path |
Example:
final bytes = Uint8List.fromList(utf8.encode('Hello, World!'));
final result = await fileSaver.save(
bytes: bytes,
fileName: 'hello.txt',
mimeType: 'text/plain',
subDir: 'MyApp', // Creates Downloads/MyApp/hello.txt on Android
);
if (result != null && result.isSuccess) {
print('Saved: ${result.fileName}');
print('URI: ${result.uri}');
print('Path: ${result.path}');
}
saveBytesWithDialog()
Save binary data with a system file picker dialog, allowing users to choose the save location.
Future<PublicSavedFile?> saveBytesWithDialog({
required Uint8List bytes,
required String fileName,
String mimeType = 'application/octet-stream',
List<String>? fileSuffixChoices, // OHOS only
})
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
bytes |
Uint8List |
Yes | The binary data to save |
fileName |
String |
Yes | Suggested file name |
mimeType |
String |
No | MIME type of the file |
fileSuffixChoices |
List<String>? |
No | File extension choices (OHOS only) |
Platform Behavior:
| Platform | Dialog Type | Returns |
|---|---|---|
| Android | Storage Access Framework (ACTION_CREATE_DOCUMENT) | uri: content:// URI |
| iOS | UIDocumentPickerViewController | uri: file:// URL, path: file path |
| macOS | NSSavePanel | uri (file://) and path |
| Web | Native Save As picker when supported; otherwise browser download | fileName only |
| Windows | IFileSaveDialog (COM) | path: full file path |
| Linux | GtkFileChooserNative | uri (file://) and path |
| OHOS | DocumentViewPicker.save | uri and path |
Example:
final jsonData = {'name': 'Test', 'value': 123};
final bytes = Uint8List.fromList(
utf8.encode(jsonEncode(jsonData))
);
final result = await fileSaver.save(useDialog: true,
bytes: bytes,
fileName: 'data.json',
mimeType: 'application/json',
);
if (result != null && result.isSuccess) {
print('User saved file to: ${result.path ?? result.uri}');
} else {
print('User cancelled or save failed');
}
saveFile()
Save a local File object to a public location.
Future<PublicSavedFile?> saveFile({
required File file,
String? fileName,
String? mimeType,
String? subDir,
bool useDialog = false,
})
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
file |
File |
Yes | The file to save |
fileName |
String? |
No | Custom file name (uses original name if not provided) |
mimeType |
String? |
No | MIME type (inferred from extension if not provided) |
subDir |
String? |
No | Subdirectory (non-dialog mode, Android, iOS, macOS, Windows, Linux) |
useDialog |
bool |
No | If true, shows file picker dialog |
Example:
import 'dart:io';
final file = File('/path/to/document.pdf');
// Save without dialog
final result = await fileSaver.save(
file: file,
subDir: 'Documents',
);
// Save with dialog
final result = await fileSaver.save(
file: file,
fileName: 'renamed_document.pdf',
useDialog: true,
);
saveFromUrl()
Download a file from a URL and save it to a public location.
Future<PublicSavedFile?> saveFromUrl({
required String url,
String? fileName,
String? mimeType,
String? subDir,
bool useDialog = false,
Duration? timeout,
})
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
url |
String |
Yes | HTTP(S) URL to download from |
fileName |
String? |
No | Custom file name (inferred from URL/headers if not provided) |
mimeType |
String? |
No | MIME type (inferred from Content-Type header if not provided) |
subDir |
String? |
No | Subdirectory (non-dialog mode, Android, iOS, macOS, Windows, Linux) |
useDialog |
bool |
No | If true, shows file picker dialog after download |
timeout |
Duration? |
No | Timeout for the HTTP request (throws TimeoutException when exceeded) |
Example:
// Download and save directly
final result = await fileSaver.save(
url: 'https://example.com/document.pdf',
subDir: 'Downloads',
);
// Download and show save dialog
final result = await fileSaver.save(
url: 'https://example.com/image.png',
fileName: 'my_image.png',
useDialog: true,
);
if (result != null && result.isSuccess) {
print('Downloaded and saved: ${result.fileName}');
}
Return Type: PublicSavedFile #
All save methods return PublicSavedFile?:
class PublicSavedFile {
final String fileName; // Name of the saved file
final String? uri; // URI of the saved file (platform-dependent)
final String? path; // File system path (platform-dependent)
bool get isSuccess => uri != null || path != null;
}
Return values by platform:
| Platform | uri |
path |
|---|---|---|
| Android 10+ | content:// URI | null |
| Android 9- | null | Full file path |
| Android (dialog) | content:// URI | null |
| iOS (direct) | null | Full file path |
| iOS (dialog) | file:// URL | Full file path |
| macOS | file:// URL | Full file path |
| Web | null | null (browser-controlled) |
| Windows | null | Full file path |
| Linux | file:// URL | Full file path |
| OHOS | File URI | Converted path |
On Web, PublicSavedFile.isSuccess uses the non-empty file name because browsers do not expose the actual save path to JavaScript.
Utility Methods #
sanitizeFileName()
Sanitize file names by replacing illegal characters.
final safeName = PublicFileSaver.sanitizeFileName('file:name?.txt');
// Result: 'file_name_.txt'
Replaced characters: \ / : * ? " < > |
Complete Example #
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:public_file_saver/public_file_saver.dart';
class SaveFileExample extends StatefulWidget {
@override
_SaveFileExampleState createState() => _SaveFileExampleState();
}
class _SaveFileExampleState extends State<SaveFileExample> {
final _fileSaver = PublicFileSaver();
String _status = 'Ready';
Future<void> _saveTextFile() async {
final bytes = Uint8List.fromList(
utf8.encode('Hello from Flutter!\nTimestamp: ${DateTime.now()}'),
);
final result = await _fileSaver.save(
bytes: bytes,
fileName: 'flutter_demo.txt',
mimeType: 'text/plain',
);
setState(() {
if (result != null && result.isSuccess) {
_status = 'Saved: ${result.fileName}\n'
'URI: ${result.uri}\n'
'Path: ${result.path}';
} else {
_status = 'Save failed or cancelled';
}
});
}
Future<void> _saveWithDialog() async {
final data = {'message': 'Hello', 'timestamp': DateTime.now().toIso8601String()};
final bytes = Uint8List.fromList(utf8.encode(jsonEncode(data)));
final result = await _fileSaver.save(useDialog: true,
bytes: bytes,
fileName: 'data.json',
mimeType: 'application/json',
);
setState(() {
_status = result?.isSuccess == true
? 'Saved to: ${result!.path ?? result.uri}'
: 'Cancelled';
});
}
Future<void> _downloadAndSave() async {
try {
final result = await _fileSaver.save(
url: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
useDialog: true,
);
setState(() {
_status = result?.isSuccess == true
? 'Downloaded: ${result!.fileName}'
: 'Failed';
});
} catch (e) {
setState(() {
_status = 'Error: $e';
});
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_status),
SizedBox(height: 20),
ElevatedButton(
onPressed: _saveTextFile,
child: Text('Save Text File'),
),
ElevatedButton(
onPressed: _saveWithDialog,
child: Text('Save with Dialog'),
),
ElevatedButton(
onPressed: _downloadAndSave,
child: Text('Download & Save'),
),
],
);
}
}
Error Handling #
All methods return null when:
- User cancels the save dialog
- Save operation fails
- Required parameters are missing
For saveFromUrl(), network errors will throw exceptions:
try {
final result = await fileSaver.save(url: 'https://example.com/file.pdf');
} catch (e) {
print('Download failed: $e');
}
Unified save API and migration #
Use PublicFileSaver().save(...) with exactly one of bytes, file, or url:
final saver = PublicFileSaver();
await saver.save(bytes: bytes, fileName: 'report.pdf');
await saver.save(file: file, useDialog: true);
await saver.save(
url: 'https://example.com/report.pdf',
headers: {'Authorization': 'Bearer token'},
timeout: const Duration(seconds: 30),
);
Bytes require a nonblank fileName. MIME type is inferred unless supplied.
Use subDir only for direct saves and fileSuffixChoices only with a dialog
(suffix choices are currently OHOS-specific). URL headers and timeout require
a URL source. Invalid combinations throw ArgumentError before I/O.
saveBytes, saveBytesWithDialog, saveFile, and saveFromUrl are deprecated
but retain their previous behavior. Replace their calls with save; for
saveBytesWithDialog, also pass useDialog: true. Local files remain unsupported
on Web. Files and downloads are still buffered in memory; timeout does not
guarantee cancellation of the underlying request. Browser save dialogs retain
their existing browser support and user-gesture requirements.
License #
MIT License - see LICENSE file for details.
Contributing #
Contributions are welcome! Please read the contributing guidelines before submitting a PR.