Android storage has changed dramatically.
For a long time, Android developers could think about storage in familiar terms:
val file = File("/some/path/file.txt")
You had a path. You opened a stream. You copied a file. You created a directory.
Simple.
Modern Android is different.
Today, an application may interact with application-private storage, shared media through MediaStore, user-selected documents through the Storage Access Framework (SAF), cloud-backed document providers, and traditional JVM filesystem APIs.
The difficult part isn't learning any one of these APIs.
The difficult part is building software that can work with different kinds of storage without forcing the entire application to understand the differences.
That is the problem that led me to build a filesystem abstraction for Android and the JVM.
The filesystem isn't always a filesystem anymore
On the JVM, this is straightforward:
val file = File("/home/user/documents/example.txt")
file.inputStream().use {
// read
}
The application has a path that refers to something on the filesystem.
Android's Storage Access Framework changes that model.
Instead of receiving a normal filesystem path, an application can receive something like:
content://com.android.externalstorage.documents/tree/primary%3ADocuments
That isn't a filesystem path.
It is a URI representing a document-provider resource.
You don't meaningfully resolve it with:
File(uri.path)
The operating system expects you to interact with it through APIs such as ContentResolver and DocumentsContract.
This creates an important architectural distinction:
Traditional filesystem
Path
↓
File
↓
InputStream / OutputStream
versus:
Storage Access Framework
URI
↓
ContentResolver / DocumentsContract
↓
Provider
↓
InputStream / OutputStream
The final result may still be a stream, but everything before that stream is fundamentally different.
Scoped Storage solved one problem, but not every storage problem
Scoped Storage was an important change in Android's storage model.
For many applications, it makes storage much simpler.
An application can use its own private directories and appropriate platform APIs without needing unrestricted access to the user's entire shared filesystem.
For applications working with photos, videos, audio and other supported media, MediaStore can also provide an appropriate abstraction.
And this is where an important point gets overlooked:
Not every Android application needs SAF.
If an application only needs its own application data, use application-private storage.
If it needs to work with supported shared media, MediaStore may be the right solution.
But there are applications where the requirement is different:
"Let the user choose a directory and allow the application to work with files inside that directory."
That's where SAF becomes particularly interesting.
SAF isn't just a file picker
One of the things that surprised me while working deeply with SAF is how easy it is to think of it as merely a mechanism for selecting a file.
It is much more than that.
With the appropriate user-granted permissions, an application can work with a selected document tree.
That tree can represent storage managed by different document providers.
And those providers don't necessarily have to represent the device's local storage.
The underlying provider could potentially represent remote or cloud-backed storage.
That means the application isn't necessarily interacting with:
Android device → physical filesystem
It can be closer to:
Application
↓
ContentResolver
↓
Document Provider
↓
Storage implementation
The application doesn't need to know whether the provider ultimately represents local storage or another storage service.
That's one of the most powerful ideas behind SAF.
But this creates a problem for filesystem-oriented code
Imagine a library with an API like:
FileOperation("documents/example.txt")
On the JVM, that's easy.
But what does "documents/example.txt" mean on Android if the actual storage root is a SAF URI?
Suppose the user selected:
content://.../tree/primary%3ADocuments
Now the library needs to interpret:
documents/example.txt
relative to that selected document tree.
It cannot simply construct:
content://.../documents/example.txt
because SAF URIs aren't ordinary paths.
The library has to resolve the path through the document provider.
For example:
Selected SAF root
↓
Documents
↓
projects
↓
example.txt
Each step represents a document-provider operation rather than a normal filesystem traversal.
This is where filesystem abstraction becomes useful.
Abstract the storage, not the application
The goal isn't to pretend that SAF and the JVM filesystem are identical.
They aren't.
Trying to hide every difference can actually make an abstraction worse.
Instead, the abstraction should hide the mechanics of accessing the storage while exposing operations that make sense to the application.
For example:
interface FileSystemUtil {
fun read(path: String): Source
fun write(path: String): Sink
fun createFile(path: String)
fun createDirectory(path: String)
fun delete(path: String)
fun copy(source: String, destination: String)
}
The JVM implementation can use:
java.io.File
while an Android SAF implementation can use:
Uri
ContentResolver
DocumentsContract
DocumentFile
The application doesn't need to know which implementation is currently providing the storage.
The interesting part: cross-filesystem operations
This is where things become considerably more interesting.
Consider:
JVM filesystem
↓
↓ copy
↓
SAF
The source might be:
/home/user/file.zip
while the destination might be:
content://.../tree/...
There is no useful way to make both sides behave like java.io.File.
Instead, the operation has to understand both storage systems.
Conceptually:
JVM source
↓
InputStream
↓
buffer
↓
OutputStream
↓
SAF destination
And the reverse is equally important:
SAF source
↓
InputStream
↓
buffer
↓
OutputStream
↓
JVM destination
This is why I eventually became less interested in simply wrapping File.
The abstraction needs to represent storage operations, not just filesystem paths.
Relative paths become interesting with SAF
Another problem appears when working with a selected SAF tree.
Suppose an application asks the user to select a root directory.
The application can then operate relative to that root:
selected root/
projects/
demo/
example.txt
The application might want to say:
createFile("projects/demo/example.txt")
rather than repeatedly passing the root URI around.
This creates a useful model:
Selected root
+
Relative path
↓
Resolved document
The implementation can traverse the document tree and create missing directories where appropriate.
That gives application code a filesystem-like experience without pretending the underlying storage is actually a filesystem.
Why not just use DocumentFile everywhere?
DocumentFile is extremely useful.
It provides a convenient object-oriented API for working with document trees and makes traversal considerably easier.
But abstraction layers also have to consider performance and API boundaries.
There are situations where direct use of:
DocumentsContract
and:
ContentResolver
is more appropriate.
In my implementation, I ended up using both approaches depending on the operation.
DocumentFile is useful for traversal and convenient document operations.
DocumentsContract provides lower-level access to document-provider operations.
The important lesson is that a good abstraction doesn't necessarily mean choosing one Android API and hiding everything else behind it.
It means choosing the appropriate mechanism for each operation.
SAF also changes what a "path" means
This is probably the biggest conceptual difference.
On a traditional filesystem:
/home/user/project/file.txt
is a path.
With SAF:
content://provider/tree/...
is an identifier for a resource managed by a provider.
That distinction matters.
A URI can contain information that looks path-like, but it shouldn't be treated as if it were a normal filesystem path.
This is why APIs such as:
File(uri.path)
are fundamentally the wrong abstraction for SAF.
A robust storage abstraction has to understand that there are different resource addressing models.
What about cloud storage?
This is another reason SAF is interesting.
A document provider can abstract storage that isn't necessarily represented as a normal local filesystem.
From the application's perspective, the interaction can still look like:
open document
read stream
write stream
create directory
delete document
The provider handles the underlying storage.
This is one of the reasons I don't think of SAF simply as "Android's annoying file picker."
It is closer to a provider-based storage interface.
The application requests operations against documents.
The provider determines how those documents are actually represented.
So where does MediaStore fit?
MediaStore and SAF solve different problems.
If you're building an application that primarily works with shared photos, videos, audio or other supported media, MediaStore is often the appropriate API.
SAF becomes more relevant when the application needs the user to choose arbitrary documents or directory trees and then work with them through the document-provider model.
A modern Android storage architecture therefore isn't necessarily:
File
or:
SAF
or:
MediaStore
It is often:
Application
|
+-----------+-----------+
| | |
App storage MediaStore SAF
| | |
private shared documents
|
Document Provider
The right choice depends on the application's requirements.
Why I built a filesystem abstraction
After working with these differences, I wanted application-level code to be able to perform filesystem-like operations without constantly knowing which storage mechanism was underneath.
That led to my filesystem library.
The idea is simple:
FileSystems.current = JvmFileSystem()
or:
FileSystems.current = AndroidSafFileSystem(...)
Then higher-level operations can work through the abstraction.
The JVM implementation deals with traditional filesystem paths.
The Android implementation understands SAF URIs, document trees, ContentResolver, DocumentsContract, and document traversal.
The application gets a consistent API while the implementation deals with the storage-specific complexity.
This doesn't make Android storage "simple"
And that's important.
A filesystem abstraction doesn't magically turn SAF into java.io.File.
There are still differences.
For example:
- SAF resources don't behave like normal filesystem paths.
- Some operations depend on what the document provider supports.
- Permissions are granted by the user.
- A provider may have different capabilities from a local filesystem.
- A URI may represent storage outside the traditional local filesystem.
- Operations such as renaming, creating documents and traversing directories have provider-specific semantics.
A good abstraction should expose those realities rather than hide them until they become bugs.
The real problem is storage integration
The difficult question isn't:
"How do I read a file on Android?"
That's easy.
The difficult questions are:
"How do I design an application that can work with multiple storage models?"
"How do I copy between a traditional JVM filesystem and a document provider?"
"How do I expose relative paths when the underlying storage is a URI?"
"How do I keep storage-specific code from leaking throughout the application?"
"How do I build a filesystem-like API without pretending every storage backend is actually a filesystem?"
Those are architecture problems.
And that's where filesystem abstraction becomes useful.
The future isn't necessarily one filesystem API
Modern applications increasingly interact with storage through abstractions.
A local filesystem is one abstraction.
MediaStore is another.
SAF is another.
Cloud document providers introduce another layer.
The important skill isn't memorizing every API.
It's understanding what storage model you're actually working with and designing your application around that model.
That's what my work on filesystem integration has taught me.
Android storage isn't just about files anymore.
It's about resources, providers, permissions, URIs, streams, and abstractions.
And sometimes, the best solution is to build an abstraction that lets the rest of the application stop worrying about which one it's dealing with.
About the author
I'm Sifiso Fakude, a software developer focused on Android storage, filesystem integration, and developer tooling.
My work includes building filesystem abstractions that bridge traditional JVM filesystems with Android's Storage Access Framework and exploring the architectural challenges created by modern Android storage.
This article was originally published by DEV Community and written by Sifiso Fakude.
Read original article on DEV Community