- Overview
- Why v3?
- Getting a
StorageFile - Creating files & folders
- Copy & move
- Conflict resolution β a suspend lambda
- Zip & unzip
- The Flow forms
- Storage access & pickers, without callbacks
- Jetpack Compose
- Terminology
- Java Compatibility
- Using the 2.x API
- FAQ
- Contributing
- Other SimpleStorage Usage Examples
- License
The more higher API level, the more Google restricted file access on Android storage.
Although Storage Access Framework (SAF) is designed to secure user's storage from malicious apps,
but this makes us even more difficult in accessing files as a developer. Let's take an example where
java.io.File has been deprecated in Android 10.
Simple Storage ease you in accessing and managing files across API levels. If you want to know more about the background of this library, please read this article: Easy Storage Access Framework in Android with SimpleStorage
Adding Simple Storage into your project is pretty simple:
implementation "com.anggrayudi:storage:X.Y.Z"
// For Jetpack Compose
implementation "com.anggrayudi:storage-compose:X.Y.Z"Where X.Y.Z is the library version:
All versions can be found here:
To use SNAPSHOT version, you need to add this URL to the root Gradle:
allprojects {
repositories {
google()
mavenCentral()
// add this line
maven { url "https://central.sonatype.com/repository/maven-snapshots/" }
}
}Version 3.0.0 introduces a redesigned API: one StorageFile
abstraction over DocumentFile/MediaFile/java.io.File, one-shot suspend operations
(copyTo, moveTo, zipTo, unzipTo) with a unified TransferResult, suspend-lambda conflict
resolution, and StorageAccessManager
replacing SimpleStorageHelper. It requires minSdk 26 and is compiled against API 37
(Android 17); all operations need Kotlin coroutines.
The 2.x API keeps working during the 3.x cycle, but 2.x is closed for maintenance: no bugfix releases will be cut from the 2.x branch, so fixes land in 3.x only. Everyone is encouraged to move to v3 β see the migration guide.
Three ideas replace most of the 2.x surface:
- One file type.
StorageFilewraps SAF'sDocumentFile, MediaStore'sMediaFile, andjava.io.Filebehind one interface. You stop caring which world a file lives in. - One operation vocabulary. Every long-running operation is a main-safe
suspendfunction returning aTransferResult, with an optionalFlow<TransferEvent>form when you need the full event stream. - One access entry point.
StorageAccessManagerturns SAF grants and pickers into plain suspend calls β no request codes, noonActivityResult, no callbacks.
// From whatever you already have:
val a = StorageFile.from(context, uri) // SAF, file://, or MediaStore URI
val b = StorageFile.from(context, File("/storage/emulated/0/Download/movie.mp4"))
val c = StorageFile.fromPath(context, "/storage/emulated/0/Download/movie.mp4")
val d = StorageFile.fromPath(context, StoragePath(storageId = "AAAA-BBBB", basePath = "Download/movie.mp4"))
val e = StorageFile.fromPublicDirectory(context, PublicDirectory.DOWNLOADS, "movie.mp4")
// Conversions from the 2.x world:
val f = documentFile.toStorageFile(context)
val g = mediaFile.toStorageFile(context)StorageFile holds its Context internally β no member function asks for one. Useful properties:
name, mimeType, length, isDirectory, exists, lastModified, canRead, canWrite,
list(), child("sub/file.txt"), openInputStream(), openOutputStream().
absolutePath and path return null when the file has no resolvable physical path (v2
returned a confusing empty string). Escape hatches back to the underlying worlds:
asDocumentFile(), asMediaFile(), asRawFile().
val folder = StorageFile.fromPath(context, StoragePath.primary("Documents"))!!
val report = folder.createFile("report.txt", "text/plain") // report (1).txt if taken
val invoice = folder.createFile("invoices/2026/q3.pdf", "application/pdf") // parents created
val archive = folder.createFolder("archive")
report?.openOutputStream()?.use { it.write("hello".toByteArray()) }name may carry subfolders; missing ones are created and existing ones are reused. The
CreateMode applies to the last
segment only β CREATE_NEW (default) keeps an existing file and creates report (1).txt beside it,
REPLACE overwrites it, REUSE returns it untouched. Both functions return null when the
receiver is not a writable folder, which is always the case for a MediaStore-backed StorageFile.
lifecycleScope.launch { // main-safe: call from any dispatcher
val result = file.copyTo(targetFolder) { // this block is optional
onConflict { ConflictResolution.REPLACE }
onProgress { progressBar.progress = it.percent.toInt() }
updateInterval = 250 // ms between progress events
}
when (result) {
is TransferResult.Success -> toast("Copied ${result.result.name}")
is TransferResult.Skipped -> toast("Skipped β ${result.existingTarget?.name} already exists")
is TransferResult.Failure -> Log.e(TAG, "${result.errorCode}", result.cause)
}
}The first progress event arrives one updateInterval after the transfer starts, so every event
carries measured numbers β and a transfer that finishes within one interval reports no progress at
all, just its result.
Several sources at once share one event stream and come back as a list:
val result = listOf(photos, notes).copyTo(backupFolder) {
onConflict { ConflictResolution.REPLACE }
}
val copied: List<StorageFile>? = result.getOrNull()When the destination is an existing file rather than a folder β a MediaStore entry, or a document
you just created β use copyToFile / moveToFile, which replace its content:
val entry = MediaStoreCompat.createDownload(context, FileDescription("report.pdf"))!!
file.copyToFile(entry.toStorageFile(context))moveTo has the same shape. Folders are detected automatically β copyTo on a directory copies
recursively. All options live in the TransferSpec
block: updateInterval, checkAvailableSpace, skipEmptyFiles (note: also skips empty
folders), fileDescription (rename in target), deleteSourceOnSuccess (zip only).
TransferResult.Failure carries a TransferErrorCode, an optional message, the causing
Throwable, and partialStats when something was transferred before the failure.
The resolver is a suspend function. Show a dialog, await the answer, return it. No callback
classes, no CoroutineScope parameter, no GlobalScope:
val result = folder.copyTo(destination) {
onConflict { conflict ->
when (conflict) {
is Conflict.TargetFolder -> // whole folder exists; canMerge tells you if MERGE is possible
if (conflict.canMerge) ConflictResolution.MERGE else ConflictResolution.CREATE_NEW
is Conflict.TargetFile -> // per-file conflict (also emitted during folder merges)
withContext(Dispatchers.Main) { askUserDialog(conflict.target.name) }
}
}
}Resolutions: REPLACE, MERGE (folders; falls back to CREATE_NEW on files), CREATE_NEW
(report.pdf β report (1).pdf), SKIP.
A top-level SKIP (single-file conflict, or the whole-folder conflict) ends the operation with a dedicated TransferResult.Skipped(existingTarget) β not a Failure, not a
Success. Per-file skips inside a folder merge keep the operation Success and are counted in
TransferStats.filesSkipped.
val zipResult = listOf(folder, extraFile).zipTo(targetZipFile) { // target must already exist
deleteSourceOnSuccess = false
}
val unzipResult = zipFile.unzipTo(targetFolder) {
onConflict { ConflictResolution.REPLACE }
}When you need every event (e.g. WorkManager notifications), use the *AsFlow variants:
file.copyToAsFlow(targetFolder).collect { event ->
when (event) {
is TransferEvent.PhaseChanged -> Log.d(TAG, "phase: ${event.phase}")
is TransferEvent.Progress -> notify(event.percent, event.bytesPerSecond)
is TransferEvent.Completed<*> -> handle(event.result) // exactly one terminal event
}
}Cancelling the collecting coroutine aborts the transfer. Also available:
deleteRecursively() (suspend) and search(recursive, name, regex, mimeTypes, updateInterval)
returning Flow<List<StorageFile>>.
Create a StorageAccessManager
in onCreate (it registers Activity Result launchers), then everything is a suspend call:
class MainActivity : AppCompatActivity() {
private lateinit var storageAccess: StorageAccessManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
storageAccess = StorageAccessManager(this)
btnBackup.setOnClickListener {
lifecycleScope.launch {
// 1. Make sure we can write to Documents β asks the user through SAF only when needed
when (val access = storageAccess.ensureAccess(StoragePath.primary("Documents"))) {
is AccessResult.Granted -> myFile.copyTo(access.folder)
is AccessResult.WrongRootSelected -> explainAndRetry(access.grantedRoot)
AccessResult.CanceledByUser, AccessResult.PermissionDenied -> showError()
}
}
}
btnPick.setOnClickListener {
lifecycleScope.launch {
// 2. Pickers are one-liners; results are the contract result types
val picked = storageAccess.pickFolder()
if (picked is FolderPickerResult.Picked) {
use(picked.folder.toStorageFile(this@MainActivity))
}
// 3. System Photo Picker β no permission, no SAF grant
val media: List<StorageFile> = storageAccess.pickMedia()
}
}
}
}Also available: pickFiles(allowMultiple, filterMimeTypes), createFile(mimeType, fileName),
requestStoragePermission().
A VolumeBookmark remembers a location on an SD card or USB OTG drive and re-resolves it after
replug:
// After the user grants access once:
val bookmark = storageAccess.createBookmark(folder) // persist it yourself
// Later β no UI when the volume ID is unchanged (mainline Android):
when (val result = storageAccess.resolveBookmark(bookmark)) {
is BookmarkResult.Granted -> use(result.folder) // persist result.bookmark: ID may have changed
BookmarkResult.VolumeNotMounted -> askUserToPlugDriveIn()
else -> showError()
}
// Optional (API 30+): react to drives being plugged in
storageAccess.volumeMountEvents().collect { volume -> maybeResolveBookmarks() }If the ID changed (some OEM builds, ChromeOS), a volume with the same label triggers a single SAF
re-grant and Granted carries the updated bookmark. There are no built-in dialogs β you own the UX around
WrongRootSelected retries. If you prefer the old guided dialogs, the deprecated
SimpleStorageHelper still works β see README-2.x.md.
All 2.x launchers still exist, plus the new Photo Picker one:
val mediaPicker = rememberLauncherForMediaPicker(maxItems = 5) { files: List<StorageFile> ->
viewModel.onMediaPicked(files)
}
Button(onClick = { mediaPicker.launch() }) { Text("Pick photos") }Others: rememberLauncherForStoragePermission, rememberLauncherForStorageAccess,
rememberLauncherForFolderPicker, rememberLauncherForFilePicker,
rememberLauncherForFileCreation.
- Storage Permission β related to runtime permissions
- Storage Access β related to URI permissions
Simple Storage is built in Kotlin and v3 is Kotlin-first, but the synchronous half of the library
is Java-callable: StorageFile and its @JvmStatic factories, metadata and folder navigation,
streams, and the ActivityResultContract pickers.
The long-running operations β copy, move, zip, unzip, search β are suspend functions and cannot
be called from Java, and neither can StorageAccessManager. Keep those call sites in Kotlin;
the two languages mix freely in one module. Staying on
v1.5.6 is the other option, but
that version is no longer maintained.
Follow this documentation for the details and code samples.
The 2.x surface (SimpleStorage, SimpleStorageHelper, DocumentFileCompat, the DocumentFile
and MediaFile extensions) still ships inside 3.x as @Deprecated and is removed in 4.0. Its
documentation now lives in README-2.x.md, and MIGRATION.md maps
each call to its v3 replacement.
Having trouble? Read the Frequently Asked Questions or join the Discussions.
CONTRIBUTING.md covers the formatting rules (ktfmt, Google style), what makes a comment worth keeping, and how the test suites are run.
Open source projects that depend on this library β a good place to see it used in anger:
- Super Productivity β advanced todo list with time tracking
- YTDLnis β audio/video downloader built on yt-dlp
- Neo Store β F-Droid client
- Thanox β app management and privacy toolkit
- Audiobookshelf β audiobook and podcast client
- OSS Document Scanner β document scanning app
- Inure β Android app manager
- MEGA β MEGA's official Android app
- Book's Story β Material You eBook reader
- PairDrop β local file sharing, formerly Snapdrop
- PhotonCamera β computational photography camera
- Nextcloud Cookbook β recipe client for Nextcloud
- Shared Storage for Flutter β SAF bindings for Flutter
Using SimpleStorage in your project? Open a pull request and add it here.
Copyright Β© 2020-2026 Anggrayudi Hardiannico A.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
