Commit Graph

85 Commits

Author SHA1 Message Date
r-zakarya e4a50df64d fix(android): protection catches up with the cache, and a dead key gets replaced
After the screen lock was removed and put back, the app stopped asking for the
pattern. Two gaps, both mine.

The cache had been rewritten while the device had no lock, so under the plain
key - and nothing ever moves it back. Protection was decided at write time
only, and a write happens only when a new snapshot arrives; a 304 left the
file in the clear indefinitely. It is now re-sealed on open, where the
plaintext is already in hand.

And the invalidated RSA key was never replaced. Removing a lock, or enrolling
a new fingerprint, permanently invalidates a protected key while its alias
survives. EnsureWrapKey only created the pair when the alias was absent, so it
kept reusing a dead key: every encryption failed and every write fell back to
the plain key, forever. It now deletes and regenerates on a second attempt.

Measured on device: header 1 before, 3 after the first launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:11:48 +01:00
r-zakarya bc89ec0010 i18n(android): the interface speaks English, like the desktop app
It was in French because the conversation is, and I never asked. The desktop
app the client mirrors says Add field, Attach file, Show previous versions -
someone moving between the two would notice the seam.

Every visible string moved: buttons, statuses, detail row captions, log lines
and the biometric prompt, plus the messages the modules surface through
LastStoreError and LastBiometricError.

Code comments stay in French. They address the author, not the user, and that
is already the language of CLAUDE.md and much of the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:59:31 +01:00
r-zakarya b900b5b609 fix(android): one frame around the search field, not two
The painted surface behind it and the style's own background were both drawing a rounded outline, nested. Now that make-style.ps1 recolours the style's, mine is redundant - and it was the outer of the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:50:20 +01:00
r-zakarya e8e305b6fd feat(android): recolour the two native controls, and hide the log by default
Dark.style has no transparent template for an edit or a memo, so a StyleLookup at them fell back silently to the normal one and its grey background - the search field and the log were the last two things clashing with a painted interface. Since the style is vendored anyway, make-style.ps1 rebuilds it from Delphi's with those two backgrounds set to the palette. Reproducible, so a palette change does not quietly lose the patch.

The log is hidden now: it only serves diagnosis and it was taking a third of the screen at every launch. A Journal button beside the server settings brings it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:43:52 +01:00
r-zakarya 5a2774974a fix(android): the folder panel closes when you tap beside it
It stayed open until something was chosen, and there was no way to change your mind. A full-screen scrim now dims the background and takes the outside tap; the hardware back button closes the panel before anything else, which is what it is expected to do while something is open.

The list also scrolls: eight folders fit, twenty would have run off the screen. Rows are placed by coordinate inside the scroll box, with no alignment at all - the only arrangement FMX has not surprised me with today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:37:43 +01:00
r-zakarya 09f108aa8d feat(android): the last two styled controls give way
The search field and the folder combo were the only things still wearing the style, and they clashed with everything painted around them.

The edit stays native - a keyboard has to come from somewhere - but its background is ours now, with the styled one made transparent. The combo becomes a painted button opening a panel of choices, placed by explicit coordinates: FMX's stacking order proved unstable as soon as children are destroyed and rebuilt, so nothing here relies on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:26:02 +01:00
r-zakarya e5eb91cd3f feat(android): paint the interface instead of wearing a style
None of the styles Delphi ships is meant for a phone - grey gradients, sharp
corners, thick frames, all from the desktop era. And the day showed that
anything rendered through a style template may not exist in the style that is
actually loaded: the list row template, the search prompt and the detail row
each vanished in turn.

So the buttons are rectangles we draw: slate at rest, cyan for the one action
that matters, rounded, no gradient, with a press feedback a TRectangle does
not have on its own - without it you cannot tell whether the tap landed. The
form's own fill carries the background, the list drops its frame, labels take
the palette.

A style stays loaded underneath, as a base only: edits, list and memo are
native controls we are not going to rewrite, and with no style at all they
would render light.

Palette lives in PMA.Theme, the same slate and cyan as the desktop app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:55:54 +01:00
r-zakarya 65e6ba6a54 fix(android): draw the search hint ourselves, TextPrompt needs a style object
Dark.style has no object for the prompt text, so the search field sat there
empty and mute. A label inside the field, hit-test off so the tap goes
through, shown only while the field is empty.

Fourth time today the same conclusion: whatever FMX renders through a style
template may or may not exist in the style actually loaded. Built explicitly,
it is there.

Also noted: the row height was never wrong. 56 logical units on a 3x display
are the ~166 device pixels the screenshot showed - only the second line was
missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:04:01 +01:00
r-zakarya 31ee1b45c6 fix(android): build the list rows by hand, styles cannot be trusted with them
The dark style took the row template with it: no username under the name, no
icon, and rows three times too tall. listboxitembottomdetail simply does not
exist in Dark.style, and Text/Detail/Bitmap render whatever the current style
decides.

Rows are now built explicitly - an image and a body layout, Left and Client,
where there is no order to guess - the same conclusion as the detail screen.
Same amount of code, no dependency on a template that may or may not be there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:55:00 +01:00
r-zakarya 49efccdd59 feat(android): settings live on the device, dark theme, and the app is called Coffre
URL and token are no longer constants. They sit in settings.bin, sealed with the no-auth Keystore AES key - deliberately weaker than the vault: they must be readable to rebuild a vault that no longer exists, and a bearer token only opens ENCRYPTED data, worthless without the sync password that stays in the guarded vault. Retyping 64 hex characters after every cache purge was the real risk: the risk of writing them down elsewhere. They are saved only after a fetch that worked - proven settings, not typed ones.

The token and sync password moved to the gitignored local include, as first-run defaults only. Tracked source now carries no secret, and leaving them empty is a viable configuration: the app asks.

Theme: Delphi's Dark.style, copied into the repo and linked as a resource rather than deployed as a file - this project's deployment puts nothing beside the app, which is the same hole that forces the manifest and dex staging. A failure to load is never fatal; the default look is ugly but works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:33:56 +01:00
r-zakarya 3a9248c6c7 fix(android): a fresh container per entry, and the filename left to read in its own direction
Row order alternated: correct on first open, upside down on the next. Neither the stacking convention nor an explicit Index stabilised it. Since the first population is reliable, there is never a second one - the scroll box is destroyed and rebuilt per entry. Costs one control; removes a whole class of oddities.

The attachment size moved from the value into the caption. An Arabic filename followed by a latin size mixes two reading directions in one line, and FMX's bidirectional rendering makes a mess of it. Apart, each reads its own way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:21:19 +01:00
r-zakarya bbb44a3787 fix(android): sync went through the door it should have knocked on, and rows kept flipping
Synchroniser rebuilt the vault from the server and displayed it without ever asking for the pattern - the same result as Ouvrir le coffre, one button away, with the protection bypassed. It now passes the same gate, conditioned on a protected cache existing on this device, since that is what expresses the user's choice.

Row order was not merely inverted, it alternated: right on first open, upside down after a lock. FMX's stacking conventions already differ between TLayout and TVertScrollBox; after rows are destroyed and rebuilt they are not even constant. Each row now states its Index explicitly rather than trusting insertion order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:15:56 +01:00
r-zakarya 4c6376edbf fix(android): the detail rows were upside down
Inside a TVertScrollBox, Align=Top children stack in creation order - the opposite of a TLayout, where the last created ends up on top. The convention that is right for the unlock screen produced an inverted form here, with the 2FA code relegated to the bottom when it is the reason you pull the phone out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:09:08 +01:00
r-zakarya 7229028060 feat(android): show attachments, without carrying their bytes
One attachment took the cache from 13 to 174 KB: the content travels base64 in the snapshot and is decrypted in memory with the rest. TVault reads name, type and size and DROPS content_b64 - holding it for the whole vault while nothing displays it would be paying for nothing.

The detail now says a document exists. Saving it is what remains, and what blocks that is not code but where a file lands on Android 10+. When that is settled, the content re-reads from the raw JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:05:08 +01:00
r-zakarya 7939d268f3 feat(android): mark the clipboard sensitive, and lock after 3 min idle
Since Android 13 the system shows a preview of whatever was just copied, at the bottom of the screen. For a password that is precisely what we are trying to avoid: it appears in clear, outside the app, for several seconds. The flag that suppresses it is a ClipDescription extra, which IFMXClipboardService does not expose - hence going straight to ClipboardManager. Note Jcontent_ClipboardManager, not JClipboardManager: the latter is the deprecated android.text one, which knows only bare text and has no ClipData to carry an extra.

Backgrounding already locks; the idle timer covers the phone left face-up on a table with the app open. It samples timestamps every 15 s rather than counting seconds - a wake-up per second would cost battery for no precision that matters.

Attachments dropped from the plan: this vault has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:58:27 +01:00
r-zakarya 8dc0840e03 fix(android): declare FMX's biometric activity, and stop LoadVault from deleting the cache
The prompt raised ActivityNotFoundException: FMX does not show BiometricPrompt from our NativeActivity, it launches its own BiometricFragmentActivity, which lives in fmx.dex.jar but has to be declared in the manifest. Given a translucent framework theme - it hosts the system dialog and draws nothing itself, and the generated resources contain no AppCompat.

Then the read failed and the cache vanished with no trace. LoadVault sorted 'key lost' from 'not authenticated' on the exception message and deleted the file when it guessed the former - so a wrong guess destroyed the cache and the only record of why. It now reports through LastStoreError and deletes nothing; the caller has the log and the context. OpenLocalNow shows the reason, after ShowUnlock rather than before, which was overwriting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:11:20 +01:00
r-zakarya 09048410e1 fix(android): report the biometric failure instead of destroying the cache
The launch-time auto-open received boUnavailable and wiped the cache, seconds after the write path had proved the device could authenticate. The diagnosis was wrong and the reaction irreversible. An automatic action now never clears the vault: it logs the cause and leaves the user on the unlock screen. Only an explicit tap on Open, followed by a clean refusal from the system, may clear it. PMA.Biometric keeps the reason in LastBiometricError, and a Java-layer exception now maps to boFailed rather than boUnavailable - an exception says nothing about the device. The log memo also wraps: it was cutting off exactly the end of the message, which is where the cause is named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:42:07 +01:00
r-zakarya 4258e9b559 fix(android): USE_BIOMETRIC, and never lock the cache behind a door we cannot open
Opening the vault threw java.lang.SecurityException: USE_BIOMETRIC is not in
Delphi default permission list, and CanAuthenticate raises instead of
answering false. So the fault showed up neither at compile time nor at
startup, but on the first unlock - after the cache had already been written
under an auth-protected key.

The permission is the shallow half. The deep half is that SaveVault was
called with a hard-coded True: we asked for a key guarded by a prompt that
could not be shown, producing a file that was unreadable forever. It now
receives BiometricAvailable, evaluated on the main thread before the worker
starts.

BiometricAvailable and AuthenticateThen also swallow what comes up from the
Java layer and map it to boUnavailable. The caller has a path for every
TBioOutcome and none for an exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:09:29 +01:00
r-zakarya 9a4170c44f feat(android): folder filter, icons, sort, template, updated_at, FLAG_SECURE
Works through the plan's §2 and §3 items that need no hardware and no
platform decision.

- allowBackup="false". The private directory holds vault.bin; a cloud
  backup has no business in a password manager's path, even though what
  would leave is encrypted by a key that stays in the chip.
- FLAG_SECURE, Release builds only - in debug it also blocks development
  screenshots. Complementary to hiding on WillBecomeInactive rather than
  redundant: that one covers the recents preview, the flag also covers
  manual captures, screen recording and casting to an external display.
- List sorted by name (snapshot order is DB order, arbitrary to the eye),
  entry icons decoded once per vault rather than on every keystroke, and a
  folder dropdown whose selection survives a background refresh. No tag
  filter: text search already matches tags.
- template and updated_at read and shown in the detail. updated_at is also
  step 1 of the write path - nothing is arbitrable without it.

Android code is now type-checked without deploying: dccaarm64 reaches the
link step and fails only on the NDK's ldandroid.exe. A JNI declaration that
does not compile no longer waits for an APK to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:23:29 +01:00
r-zakarya 3bb6f6f903 feat(android): show first, sync after
The local vault now opens by itself at launch and the GET runs behind it,
silently. A 304 shows nothing; a network failure shows nothing either -
offline is a normal state, and an alert on every launch only teaches people
to dismiss alerts. Only a new snapshot manifests, by refreshing the list.

That silence is what puts the sync password inside the Keystore-encrypted
file next to the vault: without it a new snapshot cannot be opened, so every
edit made on the desktop would raise a password prompt at launch. The file
already holds every password in plaintext; this one adds almost nothing to
its exposure. A typed password still wins over the stored one, which is how
you fix one that no longer works.

Adopting a snapshot while the app is already open rebuilds the list in place
instead of navigating - mandatory even from the detail screen, since list
rows carry indices into the old vault and a tap would open the wrong entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:14:44 +01:00
r-zakarya acf4b466e0 fix(android): breathing room under the last detail row 2026-08-15 10:50:07 +01:00
r-zakarya 20ba2438df feat(android): TOTP codes and custom fields in the detail screen
The detail screen showed site, username and password and silently dropped
everything else - custom fields, notes, 2FA. It is now built per entry
rather than from a fixed template: every row is a caption, a value, and
whether it is secret, so reveal and copy exist once instead of once per
kind of field.

Two rows stay special on purpose. A note's body is stored in the password
column, so it gets its own caption and no mask. The 2FA code is the only
live row - refreshed every second, placed first, since it is what you pull
a phone out for.

PMA.Totp takes bare base32 or an otpauth:// URI; both travel in snapshots
depending on how the entry was created. Checked against all six RFC 6238
vectors, including T=20000000000, which overflows 32 bits.

The rows hold plaintext, so clearing them is part of locking, not of
navigating away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:35:09 +01:00
r-zakarya 3232f6f163 fix(android): the ETag dies with the plaintext it describes
Unlock, lock, download again: the app sent If-None-Match, got 304, and had nothing to show - on Windows there is no local cache to fall back on, so the only way back in was restarting. FETag outlived the vault it belonged to; LockVault now clears it. The 304 branch also stopped re-reading the cache file (which can fail on an expired authentication) and serves the plaintext it already holds - in memory, or the copy read at the start of the call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 06:04:43 +01:00
r-zakarya bf7aac478b feat(android): read-only viewer - unlock, search, detail
Replaces the crypto test bench with the three screens the client actually
needs. PMA.Vault holds the decrypted snapshot and answers the search;
mainApk builds unlock/list/detail in code rather than three .fmx files.

Two safety rules live in the UI because nowhere else can hold them: the
plaintext is dropped when the app backgrounds (screen hidden on
WillBecomeInactive, which precedes the recents-preview capture), and the
clipboard is cleared after 45 s only if it still holds what we put there.

The search deliberately ignores the password field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:19:47 +01:00
r-zakarya 4effc3611d feat(android): prefill the sync password from a local, untracked include
Asked for, but it opens the whole vault once the container is downloaded, so it does not belong in a versioned file - even in a repo with no remote. It lives in PMA.LocalConfig.inc, gitignored, with a template checked in so a fresh clone knows what to create.
2026-08-15 01:04:08 +01:00
r-zakarya 3cbfd54c26 fix(android): KeyStore.load takes a LoadStoreParameter, not an Object
The one-argument overload is load(LoadStoreParameter). Declaring JObject made the bridge look for a method that does not exist - reported at runtime as 'method not found', with nothing at compile time. Same class of mistake as generateSecret earlier: the JNI signature is built from the declared types, so an approximate type is a silent break.
2026-08-15 00:56:22 +01:00
r-zakarya 9649dac7b2 feat(android): protect the local vault with biometric or device credential
The Keystore key now requires authentication, valid for 30 s afterwards - short enough that an unattended phone is not exploitable, long enough to decrypt and display. Time-bound rather than a per-use CryptoObject: BiometricPrompt's callback would need a Java class compiled into a jar, while FMX already bridges it.

Two aliases, because the auth constraint is fixed when the key is created and cannot be added later. The file header records which key wrote it, so a read knows whether to authenticate first.

A device with neither fingerprint nor lock screen cannot hold such a key, so SaveVault falls back to the plain key rather than giving up the cache. And a failed authentication no longer wipes the cache: the vault is intact, it is the user who cancelled - erasing it would cost a download and 4 s of derivation for a dismissed prompt.
2026-08-15 00:42:50 +01:00
r-zakarya 5fdb7f914e feat(android): keep the decrypted vault locally under an Android Keystore key
Opening a container costs ~4 s and the iteration count belongs to the desktop format, so the fix is to stop repeating it: derive once per snapshot, then read a local AES file which the hardware accelerates. The key is generated inside the Keystore and never leaves it - the file alone is useless, even rooted.

ETag and vault are encrypted together on purpose: an ETag outliving an unreadable vault would make the server answer 304 with nothing to show. A failed read wipes the cache rather than guessing.

Also normalises every Delphi source to pure ASCII: Delphi reads a BOM-less file as ANSI, so accented comments came back as mojibake, and each PowerShell rewrite added another layer.
2026-08-15 00:37:59 +01:00
r-zakarya a641669150 fix(android): keep the ETag only when the decryption that followed succeeded
The harness stored the ETag on download, so a first attempt with no password left a valid tag behind: the next call answered 304 and there was nothing to decrypt, precisely when the right password had just been typed. Sending If-None-Match without holding the matching plaintext trades a download for having no data - the real client needs the same rule, the tag belonging to the cached vault.

Also drops an em dash from an exception message: Delphi reads a BOM-less source as ANSI and it reached the device as mojibake.
2026-08-15 00:06:30 +01:00
r-zakarya ccab734d61 fix(android): re-enable the fetch button on every exit path, prefill the test parameters
The Exit in each error branch jumped past the re-enable at the end of the anonymous method, so one failure disabled the button for good. Wrapped in try/finally.

URL and token are now constants, with the port chosen per platform: 80 on Windows against Apache, 8080 on Android through adb reverse. The sync password is never prefilled - it opens the whole vault and this file is versioned.
2026-08-14 23:47:54 +01:00
r-zakarya d9cadf2cac fix(android): create the top-aligned controls in reverse so they appear in order
FMX stacks Align=Top controls in reverse creation order, so the URL field ended up under the buttons.
2026-08-14 23:41:41 +01:00
r-zakarya 8796bb341d feat(android): HTTP client for the sync endpoint, with ETag reuse
Fetches the opaque container and the ETag; a 304 skips both the download and the 4-second derivation, which is the whole point of keeping the tag between calls. Redirects are not followed - one to another host would send the bearer token there.

Local testing goes through adb reverse tcp:8080 tcp:80, which makes the phone reach the PC as 127.0.0.1 and satisfies the endpoint's loopback exemption. That needs usesCleartextTraffic in the manifest, marked for removal before shipping.
2026-08-14 23:37:06 +01:00
r-zakarya 4cc79a5216 fix(android): declare KeySpec so the native PBKDF2 call resolves, and report why it fails
generateSecret takes a KeySpec; passing a JObject made the bridge emit a signature no method matches, and the call failed at runtime. Swallowed as 'unavailable', which hid my own mistake for a full test round - the failure now carries its exception message.

Measured on device: the Pascal PBKDF2 takes 6651 ms, unusable per unlock. AES-GCM through JNI works, plaintext and hash match the vector, so the container format is validated end to end.
2026-08-14 23:22:56 +01:00
r-zakarya c607e26c4a feat(android): measure the native PBKDF2 against the vector instead of guessing
The Pascal loop takes 1736 ms on a desktop CPU, so several seconds on a phone - too slow for every unlock. SecretKeyFactory is native and roughly an order of magnitude faster, but PBEKeySpec takes a char[] and the provider picks the encoding, which was the reason for avoiding it.

The non-ASCII vector settles it on a real device: same key means the provider uses UTF-8 and we adopt it; a different key means the Pascal implementation stays. DecryptContainer keeps using the Pascal one until that measurement says otherwise.
2026-08-14 18:33:56 +01:00
r-zakarya 386bf74a2a feat(android): crypto self-test harness on the new FMX project
Two stages tested separately, because a global failure says nothing about where: PBKDF2 against the known derived key - which also runs on Win64, so that stage validates in seconds without deploying to a phone - then AES-GCM against the plaintext hash, Android only.

The vector is embedded as a unit rather than read from a file: no network, no deployment, so a first-run failure can only come from the decryption itself. Work runs on a thread; 600k HMAC rounds on the UI thread would have Android kill the app for not responding.
2026-08-14 18:29:12 +01:00