Proven on device, against the real repository:
GET first: HTTP 200, sha 8feb3b7d...
PUT .../write-test.json
OK 200 - new sha 67d6a4b2...
Read the sha, write with it, receive the next one. Section 4 step 3 is done.
The probe reads before writing because that is the real sequence: an empty sha
means "create", and Gitea answers 422 when the file is already there - which is
what it did, and rightly.
The second fix comes from an interaction between two changes made hours apart
today, and only visible once both were in place. Settings are stored on the
line after "received N bytes"; but now that the sha comparison works, a sync
answers 304 and returns before ever reaching it. So a freshly typed token was
never saved and vanished on close - which is exactly what the user reported.
They are now kept as soon as the transport answered at all, 200 or 304: both
prove the address and the token, only the body differs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "the token field saves nothing", and it was exact: LockVault
called PushConfig, which pushes the STORED server settings back into the page.
Every lock therefore overwrote a freshly typed token with the old one -
including the three-minute auto-lock, so it happened with no gesture from the
user at all, and the field looked like it forgot on its own.
The user's own sequence isolated it: deleting one character gave a 401, which
proves the typed value does reach the wire; then open, lock, sync gave a 200
again, the lock having restored the old token in between.
PushConfig now runs at startup only. It was pointless in the other four places
anyway: PM.wipe clears the list and the detail, never the configuration fields.
Along the way, and part of the same confusion: the three settings fields had no
labels, only placeholders that vanish once filled - and since the token was
masked this morning it became indistinguishable from the sync password sitting
right below it. They are labelled now, and the log reports both fingerprints so
a value landing in the neighbouring field shows up immediately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TSyncClient.Put publishes a container to the Gitea contents API: token header,
JSON envelope with the file base64 inside, sha omitted rather than empty when
creating, branch omitted so the repo default is used. 409 becomes its own
status, ssConflict, because it means re-read and re-apply rather than retry.
The first PUT is where a mistake costs data, so the probe writes to
write-test.json rather than the vault. Same repository, same token, same
envelope, same concurrency check - and the vault is never at stake. Reachable
from a debug-only button; the page asks Delphi whether to show it.
Two mistakes of mine on the way, both about replacing information with a guess:
- the probe did not push the config first, unlike Sync, so a token that had
just been pasted had not reached Delphi and the 403 blamed the wrong thing.
- 401 and 403 were rewritten as "token lacks write access", discarding Gitea's
body. That is an interpretation, not an observation: the same code covers a
read-only repository, a protected branch, a token from another account. The
body says which - here, plainly, "token scope=read:repository".
Also recorded: the sha comparison now short-circuits (304 where it used to
decrypt), so the difference was case or whitespace, absorbed by SameText on
trimmed values. The offending character itself was never seen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 4 step 2. EncryptContainer produces the exact container the desktop
writes: PBKDF2-SHA256, AES-256-GCM with the tag appended as WebCrypto does, a
32-byte salt and a 12-byte IV drawn per call from SecureRandom on Android and
BCryptGenRandom on Windows - never Random, since a predictable salt or IV ruins
the encryption that uses it and nothing in the output would show it.
The check matters as much as the code. A round trip with itself only proves
internal consistency, and it passed while the container carried two defects.
Node - the same primitives as WebCrypto - found both by reading a container the
phone had produced:
- Delphi's base64 wraps at 76 characters; WebCrypto's never does. Lenient
decoders accept it, but depending on their leniency is not a guarantee.
- the accented literal in the check came out of the COMPILER already broken:
this source has no byte-order mark, so Delphi reads it as ANSI. The defect
was in the test rather than the encryption, and only the cross-check could
tell those apart.
The check runs at launch in DEBUG and leaves selfcheck.json in the private
directory so it can be re-read from the PC.
Also fixed on the way: TPath resolves to FMX.Objects.TPath - a drawing shape -
because FMX.Objects comes later in the uses clause, and the error then talks
about an unknown Combine rather than a name collision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked plainly by the user: why does open trigger a sync? Because
OpenLocalNow displays the cache and then refreshes in the background, so a
change made on the desktop shows up without anyone thinking about it. Sound
intent, applied on EVERY open - including a lock followed by a re-open three
seconds later, which cannot learn anything new. Measured cost of one such
pointless refresh: 1 s of download and 5.5 s of key derivation.
A minimum interval between two background refreshes fixes that, whatever else
is wrong: the desktop does not publish several times a minute, and looking at
most once a quarter of an hour is enough for a change to arrive unnoticed.
Pressing Sync goes through another path and never waits.
The sha comparison that should already have made an unchanged refresh cheap is
still not firing - two fingerprints print identically yet compare unequal. It
is now compared with SameText on trimmed values, which cannot confuse two
distinct snapshots since a hex digest never differs by case or whitespace
alone, and the log reports both lengths so the invisible character shows up on
the next real refresh.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on the phone, opening a cached vault: 121 ms to read the sealed file,
14 ms to display it - and then 5718 ms decrypting a snapshot it already had.
The cause is upstream. A sync pushes unconditionally at the end, and the push
is never a no-op: the container is re-encrypted with a fresh salt and IV, so an
identical vault produces completely different bytes. On Gitea that is one more
commit and a new blob sha, so every other device sees a "new" snapshot and pays
a full key derivation to arrive back where it started. It also grew the
repository on every sync that changed nothing, which is the history cost the
plan warns about.
The merged state is now compared against what was pulled, as plaintext -
the only place equality is observable - ignoring snapshot_at, which changes by
construction. Equal means the push is skipped and the sync still counts as done,
timestamp included: having nothing to write is a success, not an absence.
The comparison is deliberately strict. A false negative costs one commit; a
false positive would silence a push that carried a change and lose it for the
other devices. Four tests fix where that line sits, including a modified
updated_at (which arbitrates the merge elsewhere) and a tombstone (a deletion
that has to propagate).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Against Zaki/pm_sync on Gitea 1.26.1, container uploaded through the web UI:
HTTP 200 in 1079 ms, decrypted, cache written with the sha; the next Sync finds
the same sha and skips the derivation entirely. That was the point of the ETag
on the PHP endpoint and it survives the change of transport.
One log line was lying: it announced If-None-Match, which is never sent to
Gitea - the API does not know that header, and the comparison happens on the
sha after reception. Announcing it sent you looking for a 304 from the server
that will never come. It now says "known sha" on that transport.
The plan records the two frictions met on the way, both already fixed: settings
kept only after a successful decryption (so a wrong password lost the URL and
the token every time), and the launch refresh being silent and using the
password stored with the vault rather than the field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The saved settings win over the first-run constants, so a typed URL that never
survived a successful transport kept losing to the old PHP endpoint. The
device's settings.bin was removed (the vault cache was NOT - it is the only
offline copy on that phone, and wiping it is not needed to change an address).
The Android constant now points at the contents API rather than the repo URL:
it is the one that returns the file with its sha, and the client recognises
"/api/v1/repos/" to pick that transport. Windows keeps the local endpoint - the
machine hosts it, and nothing requires it to go out on the network to read
itself.
The token stays out of the repository, as does the sync password: both live in
the gitignored PMA.LocalConfig.inc, or get typed on the device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They were saved only after a successful decryption, in the name of storing
proven settings rather than typed ones. The consequence showed up the moment
the password did not match: the URL and the token were lost on the next
launch, and both had to be typed again - an API URL and forty characters of
token, on a phone, on every attempt.
A 200 with a container in hand already proves the address and the token. The
password is a separate matter, and this call never stored it anyway. So the
settings are kept there, before decryption, and the vault plus its fingerprint
still wait for a decryption that actually worked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The log printed "HTTP 0 in 2013 ms" when no HTTP response arrived at all.
Zero is not a status code, and written that way it reads like one the server
returned - which sends you looking for the cause on the wrong side of the
connection. It now says "no HTTP reply after ... ms".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported symptom, with the local server stopped: Sync leaves the app inert -
neither Open vault nor Sync responds, and nothing is displayed. Log and
Server settings still work, because they are local to the page.
The log proves where it stops: the GET line is there, the "HTTP ... in ... ms"
line that immediately follows the call is not. So the request never returned,
SetBusy(False) never ran, and the two actions stayed disabled - a disabled
button fires no click, hence the silence. THTTPClient is given 15 s timeouts
and the Android request does inherit them, so this should not happen; rather
than keep chasing why it did, the situation is now impossible.
A deadline timer, armed while a request is in flight and longer than the HTTP
timeouts so the precise network error still wins when it works. On expiry the
interface comes back and says so. The thread is abandoned, not cancelled: it
dies when its socket fails, and if it somehow succeeds its result still shows.
The page's bridge queue had the same shape of bug: a throw while removing the
iframe left "sending" true forever, and every later command was dropped in
silence. The removal is now in a try/finally.
Also fixed while in there: SetBusy hardcoded "Sync" as the button label, so a
vault-less device lost "Download vault" on every busy transition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The caret in an Android TEdit does not land where you touch, which makes a long bearer token impossible to correct - the reason the last FMX screen had to go. A field in the engine inherits the system's text handling (caret, selection, handles, paste) for free.
With no FMX control left, two things became dead weight and are gone: the vendored Dark.style, which had nothing left to style, and Skia - 25 MB carried solely so FMX labels would shape Arabic. The engine does its own shaping; checked on the device, an Arabic filename still reads correctly. The APK drops from 22.0 to 12.7 MB.
Delphi stays the owner of the server settings; the page only edits them and hands them back when a field loses focus, one navigation per changed field rather than one per keystroke.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A swipe with nothing to scroll dragged the whole document, showing the body gradient sliding under the screen gradient - two gradients stacked and no content at all. overflow:hidden does not prevent that in an Android WebView; position:fixed does.
The lock button went missing when the list moved to HTML: it lived in the old FMX bottom bar and had no counterpart in the page. It returns as a padlock next to the search field, no label - it is the one icon universal enough to stand alone.
Icons on the unlock screen too, as code points since this source is read as ANSI. All four are basic-plane and common, so a font without them would show an empty box rather than a wrong glyph; checked on the device, they render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The FMX controls lost this one on merit. Every screen re-taught the same lessons: a template missing from the loaded style, an alignment order left to chance, a HitTest swallowing a tap, a native view painting over everything. The engine draws the same interface in forty lines of CSS and shapes Arabic on its own.
The bridge needs no local server, so no port is open on the device: evaluateJavascript carries the data down, an intercepted cmd:// navigation carries the actions up. Through a throwaway iframe - FMX always tells the engine it did not handle the navigation, which on an unknown scheme replaces the page with an error screen.
Secrets do not travel over that bridge. The list gets names, sites and usernames, which are already on screen; copying from a row sends an index and Delphi reads the vault itself. Only the open entry has its values in the JS heap, and PM.wipe clears them on lock.
Folders become a row of chips with counts instead of a full-screen panel: one tap, and the count says what to expect.
Also gone: about eight hundred lines of the screen this replaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card no longer swallows the tap: it covered the whole row and OnItemClick never fired. It paints, nothing more - HitTest false - and its children keep theirs. The two buttons take a Left and a Right rather than fixed coordinates, which put them outside the frame.
The sync password joins the URL and the token: it is a server parameter, and it filled the home screen although it is typed once. The panel opens by itself when one of the three is missing - folding away empty fields hides the only thing left to do.
And an HTML probe, to answer a question rather than argue it: the same list painted by the system engine. It renders well, Arabic included. Two lessons: FMX passes a nil encoding to loadDataWithBaseURL, and this source file carries no byte-order mark, so Delphi reads it as ANSI and an Arabic literal is already broken at compile time - hence the numeric references. Neither is a weakness of the engine, which is what I first suspected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cross moves inside the search field, where it belonged. My first attempt failed with a TLabel, whose HitTest is false by default, and I concluded too quickly that no child of a TEdit can take a touch. A TRectangle takes it.
Buttons drop the pill and take the fields' radius: a stadium beside a 14-radius field put two shape vocabularies on one line, and consistency is worth more here than fidelity to the reference.
List entries get their own block, like the detail fields - on a gradient a bare line of text has no readable edge. And each row carries User and Pass buttons, so the commonest gesture no longer requires opening the entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Showing and hiding the clear button changed the field's width on every keystroke, so the whole row jumped. Its slot is now reserved permanently and only the cross itself appears - the field keeps one width.
The magnifier also fades once typing starts: it was there to say what the field is, and once there is a query it tells you nothing you cannot see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app sat in a frame that did not belong to it: a grey status bar above, a WHITE navigation bar below, under a blue background. The reference bathes both in the same gradient, and that is what makes an app look like it occupies the screen rather than being placed on it.
Each bar takes one end of the gradient - top colour above, bottom colour below - so the continuity is exact. The FLAG_SECURE condition moved from the uses clause into the body of SecureScreen, since the same JNI imports now serve the bars, which must work in debug too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They carried the same fill and the same pill, so the password field looked like a command. A field now holds 10 per cent white against a button's 18 and a main action's 30 - hollow against raised, which is the only cue left once borders are gone.
The settings panel also reclaimed the top of the screen, above the title: created hidden, FMX moves it to the head of the stack when it becomes visible. Its position is now reasserted each time it appears.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>