The problem
Android's file pickers read MediaStore, and MediaStore is a media index. It
knows photos, video and audio. It does not know what a .md note is, or a
.log, or an .epub, or a firmware image.
So a file sitting plainly in Downloads becomes a file you cannot attach, cannot share, and in some apps cannot even see. Nothing is broken — the index is doing exactly what it was built to do. It is just being asked a question about the filesystem, and it is not a filesystem.
FOLD reads the filesystem. MediaStore is never consulted anywhere in the codebase. That is the whole premise, and everything else follows from it.
Naming what Android will not
A file manager that reads the real tree immediately owns a problem the media index quietly hid: deciding what a file is.
FOLD resolves its own MIME types — an extension table covering the types Android
misses, magic-byte sniffing behind it, and application/octet-stream as a last
resort. That last one matters more than it looks:
application/octet-stream is a type, not a reason to hide a file. A file
FOLD cannot name gets a ? badge and stays in the list.
Hiding a file you cannot classify is how you end up with the pickers this app exists to replace.
The decision the app rests on
Every read and write goes through one interface:
interface FileSystemProvider {
suspend fun list(path: FsPath, options: ListOptions): Result<List<FsEntry>>
suspend fun read(path: FsPath): Result<InputStream>
suspend fun write(path: FsPath, append: Boolean): Result<OutputStream>
suspend fun move(from: FsPath, to: FsPath): Result<Unit>
suspend fun delete(path: FsPath): Result<Unit>
fun observe(path: FsPath): Flow<FsChange>
val capabilities: FsCapabilities
}Two implementations sit behind it: RawFileProvider over the java.io.File
tree, which needs MANAGE_EXTERNAL_STORAGE, and SafDocumentProvider over
Storage Access Framework tree grants.
The indirection is not architecture for its own sake. Play Store review for All Files Access can be refused, and the policy can tighten after a release. An app hard-wired to raw file access dies with that decision; built this way, a refusal degrades the app instead of ending it.
What makes it real rather than aspirational is that the UI reads
FsCapabilities rather than a permission flag, so reduced-permission operation
is a mode with its own screens instead of an error state bolted on afterwards.
The provider factory refreshes on every resume, so a permission revoked in
system settings while the app was backgrounded swaps the provider mid-session
instead of throwing SecurityException out of a screen that assumed raw access.
Where the security checks live
FOLD runs a small HTTP server on the phone — PIN-protected, discoverable over mDNS — so any device on the same Wi-Fi can open the address in a browser. Files stay on the phone. There is no account and no relay.
That server takes paths from strangers on the network, which makes path traversal the top risk. So the check does not live in a route handler:
HTTP route ─┐
Browser UI ─┼─→ FileSystemProvider ─→ PathGuard ─→ filesystem
Indexer ──┘An endpoint added later, or a bug in one that already exists, cannot route around
it. The server's handlers do no path resolution of their own — they turn a string
into an FsPath and hand it over.
PathGuard canonicalises first, because textual normalisation is not enough: a
path through a symlinked directory normalises to something harmless while
resolving somewhere else entirely. Then denied roots win before any allowlist
is consulted, and allowed roots are compared segment-wise so a sibling directory
whose name merely starts with an allowed root's name is not treated as inside it.
Refusals never say where the path resolved. That message can reach an HTTP
response body, and confirming a resolution maps the device for whoever is
probing — so every refusal answers 404 with an identical body, and "exists but
forbidden" is indistinguishable from "does not exist" from outside.
The vault, and enforcing exclusion once
Vault files are AES-256-GCM, with a two-level key hierarchy:
Android Keystore
└── KEK (AES-256, setUserAuthenticationRequired, per-generation alias)
└── wraps DEK (AES-256, fresh per file)
└── encrypts payload (AES-256-GCM)Two levels because unlocking is then one Keystore operation rather than one per file, a single file can be re-keyed or removed independently, and rotation re-wraps a few hundred small keys instead of re-encrypting gigabytes. Each blob records the alias that wrapped it, so rotation does not have to be atomic — an interrupted rotation leaves a mixed-generation vault that still opens.
BiometricPrompt receives the Cipher and hands it back authenticated, which
binds the authentication to the operation. Authenticating and then using the
key separately — the shape most tutorials show — is satisfied by anything that
can fake the success callback.
The part I am most pleased with is not the crypto. Vault contents are excluded
from the index, search, thumbnails, widgets and the LAN server, and none of those
five components enforces that. It is enforced once, in
VaultLocations.deniedRoots, which PathGuard consults on every resolution.
VaultLocations sits in :core:storage rather than :core:crypto deliberately:
the storage layer has to know which directory to refuse before the crypto layer
exists in the dependency graph, because the refusal cannot be conditional on the
vault feature being wired up. An exclusion that five components have to
remember is an exclusion that one of them will forget.
Privacy as a checkable claim
FOLD collects nothing and transmits nothing off-device: no analytics SDK, no crash reporter, no advertising id, and no network call to anything but the phone's own LAN server.
That is easy to write and worth nothing unless it can be checked, so
docs/PRIVACY.md states it in the form the Play Data Safety questionnaire asks
for and then says how to verify it rather than asking anyone to take it on faith.
Current state
Under active development and not yet released, which the repository says plainly rather than implying otherwise.
CI assembles a debug APK and runs the unit suites on every push and pull request.
Every module compiles and 74 tests pass, covering the security-critical logic:
path canonicalisation and containment, MIME resolution, path modelling, the vault
blob header and key-alias versioning, and the LAN server's authentication and
rate limiting. No instrumented tests exist yet and the app has not been run
across a device matrix — both are in docs/STATUS.md, alongside what remains
before a release.
MIT licensed. minSdk is 30, because MANAGE_EXTERNAL_STORAGE does not exist
below it and the app's whole premise depends on it.