Files on Android: set up, unlock, browse, add, folders, delete, promote

The screen, the Storage Access Framework resolver behind
`LocalVault.ReferenceResolver`, and the passphrase-protected identity the
vault opens under.

🔴 **Android asks for a Files passphrase, as iOS does, and that was a
decision rather than a port.** The Android wallet sits behind the
Keystore-sealed vault alone, so an unlocked phone opens it. Files does
not: the identity record is encrypted under a key stretched from a
passphrase this app deliberately keeps no copy of, with the Keystore seal
around it as the second layer. Dropping the inner one for convenience
would be a real reduction and not a refactor — a vault that opens because
the phone is open is a folder with a lock drawn on it.

🔴 **`OpenDocument`, never `GetContent`.** `GetContent` hands back a URI
whose read grant dies with the process, so a linked file works for the
rest of the session and is broken on the next launch — and whoever added
it watched it work. `filesReference` REFUSES when the grant cannot be
persisted and offers to copy the file in instead, rather than filing a row
that will be dead tomorrow.

🔴 **Locking drops the identity; it does not hide a screen.** The open
vault is a field, deliberately not part of `FilesState` — a state object
gets logged, compared and copied into recompositions, none of which should
ever have a private key in it.

The classification ordinals are pinned by a change-detector test, on
purpose: the NUMBER travels with a file and the word is only how it is
drawn, so renumbering them would file a Restricted document as Internal on
the other phone with nothing erroring and neither screen looking wrong.

⚠️ **Not yet run on a device.** The machine was carrying an iOS simulator
at 99% CPU on 8 cores, and booting an emulator on top of that produces a
flaky run rather than evidence. Two things need a real device before this
is done: the SAF picker with its persistable grant, and whether a linked
file still reads after a reboot. Neither can be faked on the JVM.

⚠️ Files is its own destination, not a section of the wallet. They share an
unlock, which is true and is an implementation detail; iOS grouped them
once and what it produced was somebody pressing "wallet" and arriving at a
file list.

Full build and all JVM suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-04 15:22:56 -04:00
co-authored by Claude Opus 5
parent 3a2c10f7bc
commit fbf2c4c531
8 changed files with 1161 additions and 3 deletions
@@ -1720,6 +1720,39 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/** Forget the wallet entirely. The phrase is then the only copy. */
fun forgetWallet() = storage.delete(walletName)
// -- The Files identity --------------------------------------------------
private val filesIdentityName = "files-identity"
/**
* The passphrase-protected Files identity.
*
* 🔴 **Sealed twice, and the second seal is the one that matters.** The
* vault around it is bound to this device's Keystore key, so the record does
* not leave the phone. The record ITSELF is encrypted under a key stretched
* from a passphrase this app deliberately does not keep — which is what
* makes an unlocked, borrowed phone still not open somebody's files.
*
* ⚠️ Dropping the inner seal for convenience would be a real reduction and
* not a refactor: everything about Files assumes the classification means
* something, and a vault that opens because the phone is open is a folder
* with a lock drawn on it.
*/
fun filesIdentityRecord(): FilesIdentity.Record? =
readJson(filesIdentityName)?.let {
runCatching { FilesIdentity.Record.parse(it.toString()) }.getOrNull()
}
fun saveFilesIdentityRecord(record: FilesIdentity.Record) =
writeJson(filesIdentityName, JSONObject(record.json()))
/**
* ⚠️ Forgetting this makes every vaulted file permanently unreadable — the
* label keys are derived from the identity and exist nowhere else. Only
* "erase this phone" and "delete account" should ever reach it.
*/
fun forgetFilesIdentity() = storage.delete(filesIdentityName)
// -- Home-record rollback floor (§20.4) ----------------------------------
private fun homeSerialName(fnid: String) = "home-serial-${Bytes.b64(Bytes.sha256(fnid.toByteArray(Charsets.UTF_8))).take(22).replace('/', '_')}"