saf — one class for the Android Storage Access Framework

Saf CI pipeline status

Saf

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

Highlights

  • One class, no ceremony — pickers, permissions, file management, and I/O all on Saf; no isDir parameters anywhere.
  • Typed errorsSafPermissionException, SafNotFoundException, SafAlreadyExistsException, SafIoException.
  • Recursive walk() plus recursive copyTo / moveTo with progress callbacks.
  • Streaming I/O — backpressured readFileStream and one-call writeFileStream for large files.
  • File descriptorsopenFileDescriptor / withFileDescriptor hand native or path-based APIs a live /proc/self/fd/<fd>.
  • Thumbnailsthumbnail() 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 with copyDirToLocal.
  • Broad support — Dart ≥ 3.0, Flutter ≥ 3.10, Android minSdk 21.

Quick start

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
}

Migrating

From saf 1.x

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.

Scope

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.

Architecture

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.

Try it in 30 seconds

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.

Quality

  • 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


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.

Libraries

saf
Flutter plugin for the Android Storage Access Framework (SAF): directory/file pickers, persisted permissions, file management with recursive walk, streamed read/write with progress, and local-file bridging — in a single Saf class.