Scoped storage broke File('/storage/emulated/0/…'). saf is the fix.
One package for the Android Storage Access Framework: pickers, persisted
permissions, file management with recursive walk, streamed read/write with
progress, thumbnails, raw file descriptors, and local-file bridging — all in
a single Saf class. Ask the user once, keep the grant forever, and work
with their files in a handful of lines instead of hundreds of lines of
platform code.
flutter pub add saf- One class, no ceremony — pickers, permissions, file management, and I/O all on
Saf; noisDirparameters anywhere. - Typed errors —
SafPermissionException,SafNotFoundException,SafAlreadyExistsException,SafIoException. - Recursive
walk()plus recursivecopyTo/moveTowith progress callbacks. - Streaming I/O — backpressured
readFileStreamand one-callwriteFileStreamfor large files. - File descriptors —
openFileDescriptor/withFileDescriptorhand native or path-based APIs a live/proc/self/fd/<fd>. - Thumbnails —
thumbnail()returns provider-generated JPEG bytes for images and videos. - Persisted permissions — grant once, reuse across restarts; list them with
persistedPermissions(). - Hidden folders — read dotfile folders (e.g. WhatsApp
.Statuses) and pull them into your app dir withcopyDirToLocal. - Broad support — Dart ≥ 3.0, Flutter ≥ 3.10, Android minSdk 21.
import 'dart:convert';
import 'dart:typed_data';
import 'package:saf/saf.dart';
final saf = Saf();
// 1. Ask once — the grant persists across restarts.
final dir = await saf.pickDirectory();
if (dir == null) return; // user cancelled
// Later launches: reuse the grant instead of prompting again.
final grants = await saf.persistedPermissions();
// 2. Manage files.
final files = await saf.list(dir.uri);
final report = await saf.mkdirp(dir.uri, ['reports', '2026']);
await for (final entry in saf.walk(dir.uri)) {
print(entry.relativePath);
}
// 3. Read and write.
final doc = await saf.writeFileBytes(
report.uri, 'summary.txt', 'text/plain', utf8.encode('hi') as Uint8List);
final bytes = await saf.readFileBytes(doc.uri);
final stream = await saf.readFileStream(doc.uri); // large files
// 4. Bridge to real file paths when another API needs one.
await saf.copyToLocalFile(doc.uri, '${cacheDir.path}/summary.txt',
onProgress: (p) => print('${p.bytesDone}/${p.totalBytes}'));
// 5. Thumbnails for a gallery grid — provider-generated, no full decode.
final jpeg = await saf.thumbnail(doc.uri, 256, 256, 80); // Uint8List? for Image.memory
// 6. Hand a SAF file to anything that wants a real path or fd —
// video players, PDF renderers, sqlite — no copy, auto-closed.
final title = await saf.withFileDescriptor(doc.uri, 'r', (fd) async {
return someNativeLib.readMetadata(fd.path); // /proc/self/fd/<fd>
});Errors are typed — catch what you care about:
try {
await saf.delete(uri);
} on SafPermissionException {
// re-pick the directory
} on SafNotFoundException {
// already gone
}The old path-based class still works as LegacySaf (deprecated, removed in
3.0.0): rename Saf( → LegacySaf( and migrate at your own pace. The new API
is URI-based — start from pickDirectory() and store URIs, not paths.
saf keeps a focused, purposeful API. Raw file descriptors and provider
thumbnails now ship (see openFileDescriptor / withFileDescriptor and
thumbnail). Intentionally out of scope for now:
- Media picking — use
image_picker/photo_manager, which specialize in it.
saf is a single package with a mockable platform-interface layer, a thin Dart
facade, and one coroutine-based Kotlin handler on a dedicated channel — the
legacy 1.x channels are left untouched.
Flow: your app → Saf (facade) → SafPlatform → method channel → SafV2Api (Kotlin, coroutines) → DocumentsContract → Android SAF. All I/O runs off the main thread.
See the architecture page for layer diagrams and a grant-then-read sequence.
The bundled example is a mini file manager: pick a folder, browse
it with live image thumbnails, tap a file for its details and file-descriptor
path, write a file back. cd example && flutter run.
- Tested — full unit-test suite on the Dart layer; exercised end-to-end on physical devices.
- Hardened — the file layer is written defensively: an aborted write never deletes a pre-existing file, recursive copy refuses to recurse into itself, and every picker/stream failure path cleans up after itself.
- Typed & mockable — sealed exceptions and a platform-interface layer you can fake in your own tests.
- Documentation site — guides and recipes.
- Saf class reference — every method, generated dartdoc.
Built & maintained by jvoltci · Docs · Issues · pub.dev · MIT License
⭐ If saf saves you time, star the repo — it helps other Flutter devs find it.