Skip to content
JEEL.GAJERA/ CASE STUDY · FOLD

// case study 05 · android file manager

FOLD

Android's pickers read a media index, so a file you can plainly see becomes a file you cannot attach. FOLD reads the filesystem instead — with a hardware-backed vault and LAN sharing that never leaves the device.

TYPE
ANDROID FILE MANAGER
STATUS
ACTIVE DEVELOPMENT
READING
5 MIN READ
SURFACES
UNRELEASED · 74 UNIT TESTS GREEN IN CI

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:

code
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:

code
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:

code
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.