A fixed radius of 26 on a button only 40 tall makes the corners meet, and the shape stops being a rounded rectangle at all. The radius is now recomputed from the height whenever it changes - the height is set after creation, which is why a constant could never be right.
And the small row buttons take a moderate radius rather than the full pill: they are low and wide, and a stadium shape read as an oval. The fully rounded form stays where the reference puts it, on the main actions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not the desktop's slate any more: a blue gradient behind, blocks that are only translucent white laid over it, no borders at all, wide radii, white text. Depth comes from transparency, which is why the frames are gone - a hairline reads as a desktop application immediately.
There is no coloured accent in the reference either: primary and secondary differ by how much white they carry, 30 against 18 per cent. The 2FA code goes white, carried by its size. Nothing is uppercase - the second plane is marked by colour and size alone.
Three compiler traps on the way, all worth knowing: TBrush needs FMX.Graphics in the INTERFACE clause, a fresh TGradient already runs top to bottom, and TForm already owns a parameterless PaintBackground that shadowed the unit's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the screen lock gone the cache stays sealed under a key the device can no longer use - correct, and final. But the app kept showing Open vault, which can only fail, and called the other button Sync when what is needed is a full replacement.
A session flag remembers that the cache refused to open. The file is still not touched; only the offer changes - Open vault disappears, the remaining button reads Download vault, and the status says why.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I had followed the desktop capture - a squared field with slightly rounded corners - without thinking of the search bar the phone itself uses. On a phone that pill is the reference, and the app should look like where it runs.
Edits become pills; the memo keeps its sober corners. A magnifier is drawn inside the field, as a circle and a handle rather than a glyph, since no font is guaranteed across devices. Drawing inside a TEdit works - it is only the clickable that does not, which is why the clear cross stays beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the screen lock left no way forward: the cache is marked protected so it refuses to open, and the download that would have replaced it was blocked by the very gate I added to Sync yesterday. Neither door.
The gate now applies only when the device can actually authenticate. Demanding an authentication the phone can no longer produce protects nobody - the content comes from the server, and it is the sync password that guards it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was a TLabel, and in FMX a label does not take clicks - HitTest is False by default there. But fixing that alone would not have been enough: a child placed inside a TEdit does not reliably receive touches, the native control claims them across its whole surface.
It now sits in a wrapper next to the field, as a transparent rectangle with the cross drawn inside it - one Right and one Client, so there is no stacking order to guess either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section captions go uppercase, small and muted, the way the desktop app labels TWO-FACTOR (TOTP) or CUSTOM FIELDS - the eye jumps between captions without confusing them for values. Each field sits in its own bordered box: it is the border that separates values, not the spacing. Buttons take the same thin border instead of a solid grey block.
The 2FA code turns cyan and gains its countdown bar, so a glance says whether there is time to copy it. The seconds leave the text - the bar says it better.
And a cross clears the search, shown only when there is something to clear. A label rather than a button: inside a field, a cross is touched, not commanded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Locking the instant the app went behind made the commonest gesture painful:
copy a password, switch across to paste it, come back - and authenticate
again, with the 2FA code once more behind a prompt.
The screen is still hidden immediately and unconditionally; that is the part
that protects, and it still runs before the recents snapshot. Only the
plaintext now survives, for thirty seconds. Long enough for a round trip,
short enough that a phone left down does not stay open. Returning in time
restores the screen you left, detail or list.
Also drops the last ClearVault, in the manual open path. Nothing in the app
destroys the cache any more - only a successful write replaces it. An
unreadable file costs a few hundred kilobytes; three times today a wrong
verdict cost the cache instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The re-seal never ran and left no trace, because a branch not taken logs nothing. It logs now, verdict and reason, whether or not anything happens - the absence of a line was itself the thing that cost time.
And the verdict may have been wrong: asking canAuthenticate for BIOMETRIC_STRONG together with DEVICE_CREDENTIAL can fail outright on a device with a pattern and no enrolled fingerprint, instead of keeping what exists. It now falls back to the credential alone, which is all the key needs, and AuthenticateThen presents whichever set answered - offering something else would draw the same refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The retry I added yesterday never fired, and could not: the public key is
rebuilt through KeyFactory, so it is an ordinary RSA key with no link to the
Keystore. It encrypts perfectly well while its private half is permanently
invalidated. The re-seal therefore sealed the cache with a key nothing would
ever open again - the app asked for the sync password instead of the pattern.
Invalidation is observable at exactly one moment: reading with the private
key. LoadVault now drops the alias when the system names the cause, so the
next write mints a fresh pair instead of sealing another unopenable file.
And the caller that re-seals a plain cache asks for a fresh key outright:
protection reappearing on the device is precisely the event that invalidated
the old one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.