Commit Graph

131 Commits

Author SHA1 Message Date
r-zakarya 642dfdb077 perf(android): the key is derived once per session, not once per save
Adding an entry took five to seven seconds. The log now says where they
went: key derived in 4395 ms. Every save re-derived the master password -
600000 iterations - to arrive at exactly the key that had just been used.

The remedy was already half written: EncryptContainerWithKey exists for
this reason, but only within a single save. The key now lives for the
session, and is reused only when the container carries the same salt and
the same iteration count - the desktop may have re-encrypted its own with
a fresh salt, and a key that does not fit fails on GCM's tag while the
message blames the password, which would be right.

Nothing new is exposed: the plaintext vault is already in memory, and the
cache already holds the password that produces this key. It goes with
them, at lock - salt included, or the next open would think it holds a
usable key and decrypt against nothing.

The envelope is read in one place now rather than copied per entry point:
at three copies, a drift between them would surface as a wrong-password
error.

Measured against the real repository: HTTP 200, decrypted, 8 entries - so
the envelope refactor holds. The reuse line itself is not yet measured on
the device.
2026-08-21 14:41:35 +01:00
r-zakarya 078d548110 fix(android): a refusal always hands control back
The form disables its Save button on send, so a refusal that only speaks
leaves it dead with the typed text behind it - which is what happened to
the one guard added with entry creation: name it, press Save, nothing.

The rule had already been forgotten twice before, at the editor. It is a
procedure now rather than two calls side by side, because two calls side
by side is exactly what gets written as one.

Measured on the device: refuse, let the message fade, refuse again - the
second press produces the message, so the button had come back.
2026-08-21 14:29:56 +01:00
r-zakarya d65fe909b4 feat(android): entries can be created on the phone
A vault you cannot add to is not a vault, so an account created here was
useless without this. A plus button next to the padlock, a form, and a
kind - login or note, which the vault distinguishes and whose body is the
password field either way.

One save path now, two transports, and the rule that decides between them
is: if a local container exists it is the authority. A vault born here has
no repository; a vault pulled from one has no local container. The case
where both exist - a local vault that later gains a repository - needs a
merge, and that is section 7 step 4.

The patch arrives as a function rather than an already-patched snapshot:
the remote path must modify WHAT IT HAS JUST PULLED, or it would overwrite
whatever another device published in between. That constraint is why the
two transports could not simply share a finished payload.

Two traps closed while passing: the form left the typed password sitting
in the document after the save, and a refusal left its Save button dead
with the text behind it.

Verified on the device that the button and the form appear and read
correctly. Saving was not exercised - the vault on this phone syncs to a
real repository, and a test entry would have been published to it with no
way to delete it from here yet.
2026-08-21 14:22:08 +01:00
r-zakarya f32cde81fc feat(android): a local account, so the app serves without a repository
Until now the phone could only read a vault that came from elsewhere:
without a URL, a token and a sync password it showed nothing. It now
signs in like the desktop does - username and master password - and can
create its own vault, with no network at all.

The local container carries exactly what the repository would carry: same
format, same encryption under the password. Two reasons, not one. It is
the desktop's model, where the master password protects the vault at rest
rather than the hardware alone; and the format staying the repository's
means sync keeps working with nothing to convert. No Keystore on that
file - it is already encrypted under the password, and binding it to the
device would make it unrecoverable after a reinstall. vault.bin keeps
that role: it is the fast-open cache, and only it needs sealing.

The password opens the container - the decryption itself is the check, no
secret is ever compared - and the username, read from inside once open,
says whose vault it is.

The home screen now knows which of three situations it is in: nothing
here yet, a container without a cache, or a cache ready to open.

Verified on the device: with the cache set aside, the screen offers
username, master password and Create vault, alongside the download path.
Creating an account could not be exercised to the end - the device locked
its screen mid-run - and the vault that was set aside is back in place,
same size, still sealed under its code.
2026-08-21 14:07:39 +01:00
r-zakarya afc6119cf8 fix(android): the unlock code is asked for, and never undone in silence
Two defects, one feature made useless. The cache did seal under the code,
but nothing on the home screen asked for it: opening without one failed,
the failure was read as corruption, and the device offered to download a
vault it held perfectly well. And the next refresh rewrote the cache in
the old shape, so the code was undone seconds after being set, silently.

The code now lives on the home screen, not among the server settings: it
seals this device's cache, while the other three fields describe the
shared remote. Always visible - hiding it when none is set would remove
the only place to set one - the hint saying which case you are in. An
absent code is no longer attempted, a wrong one is no longer corruption,
and a sealed cache is never rewritten without its code.

The two panels each got an arrow, and only one opens at a time.

Measured end to end on the device: version byte 1, code typed, open, byte
4; cold start says "Unlock code required" and open without the code
leaves the byte at 4; correct code opens the vault, and the refresh that
follows leaves it at 4.
2026-08-21 13:04:48 +01:00
r-zakarya 8f8a8ec3f4 fix(android): the back key folds the keyboard before anything else
It was our own handler. The web view is not a field the system knows, so
it does not intercept the key for us - it arrives here, and we consumed
it in every case: the vault locked, or the list came back, while the
keyboard stayed up with no way left to close it.

hideSoftInputFromWindow answers whether there was anything to fold, which
is the synchronous signal needed to decide. Something folded, key eaten;
nothing folded, the key keeps its usual meaning.

Measured on the device, before and after: mInputShown stayed true, it now
goes false and the application stays in front. A second press locks, as
before.
2026-08-21 12:48:29 +01:00
r-zakarya 90ea09a718 fix(android): the keyboard closes, and the write test is gone
Same origin as the sideways slide. The keyboard's validate key asks for
the next field, and the next one in the document sits on a view parked
outside the frame. Focus landed there unseen: the keyboard changed shape
- numeric for the code, alphabetic for the search - and would not close,
because the field holding it open was off screen.

No field of a resting view keeps the focus now, and the validate key
closes the keyboard instead of jumping. In the editor it saves, which is
what the key is for after typing a password.

The write test goes with it: it proved the write path before the first
real PUT, that path now carries the vault itself, and the button, its
handler, the probe and the debug hook that revealed it were only ever
scaffolding.
2026-08-21 12:39:22 +01:00
r-zakarya 3392bf7c7a fix(android): the unlock code re-seals the cache when the vault opens
Setting a code and syncing did nothing, and the log said why: "304: unchanged,
vault served from cache". A sync only writes when the remote actually changed,
and it is the write that re-seals - so vault.bin kept its old format and its
old date, and the code looked inert. My instruction to type the code and sync
was wrong; I gave it without following the path.

Re-sealing belongs where the plaintext is already in hand: opening the vault.
That spot has a precedent - the existing path that re-seals under
authentication when a screen lock reappears - and the code now takes priority
over it, since the two protections do not stack.

The log also names the format it opened, so "sealed under the unlock code" can
be read rather than inferred from the absence of a warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:21:18 +01:00
r-zakarya a41eeb8bfa feat(android): an unlock code that survives a screen-lock change
The reported pain: changing the pattern destroys the local cache every time.
That is Android doing exactly what it promises - a key created with
setUserAuthenticationRequired is invalidated the moment the screen lock
changes, so a new lock cannot open data sealed under the old one. No storage
format changes that, which is why SQLite would not have helped: the problem is
what the key is tied to, not what the file looks like.

So the cache gains a third sealing format, VER_PIN, with two layers:

  inner - AES-GCM under PBKDF2(code, 100k) : it takes the code
  outer - the Keystore AES key WITHOUT the auth constraint : it takes the device

The outer key is never invalidated, so the file survives any change to the
screen lock; the code restores the protection that removing the constraint
would otherwise cost. 100k iterations rather than 600k because a four to six
digit code carries little entropy - more rounds mostly slow the honest user,
and the real barrier is the outer layer requiring this phone.

The code lives in a fourth settings field and is NEVER stored: empty at every
launch, typed to open, and used to re-seal on every write. That is what
separates it from the token, whose persistence is a convenience with no
consequence since it only opens ciphertext.

Built but NOT installed - the phone went off the network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:10:51 +01:00
r-zakarya 7528146a4e fix(android): the save actually reuses the derived key
The log named it exactly: "Cle ou sel absent pour le rechiffrement". The edit
that swapped DecryptContainer for DecryptContainerEx had never been applied -
the timing line from the same change was missing too, which is what should have
told me. So the key was never collected and re-encryption refused.

Worth keeping in mind: the guard in EncryptContainerWithKey did its job. It
refused rather than encrypting under an empty key and writing a container
nobody could read into the vault. A failed save is an inconvenience; a
published unreadable snapshot would have been the kind of loss this whole
section is built to avoid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:32:26 +01:00
r-zakarya 587536bb68 fix(android): every refusal hands the screen back
Reported together, and the second explains the first: Cancel did nothing
because Save disables it and nothing re-enabled it - a disabled button fires no
click. And the screen never came back because the entry guards (busy, no uuid,
no sync password) returned with a toast and nothing else, leaving the editor
open with every control dead. A cul-de-sac with no way out but killing the app.

Each guard now calls PM.editDone, which redraws the row from the last detail
received. The page can leave edit mode without asking Delphi for anything,
which is what it needed to recover from a refusal that never reached the
network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:27:17 +01:00
r-zakarya 4d9fd2c34a fix(android): a failed save can no longer hang on "Saving..."
The except only covered decryption and the patch. An exception from
re-encryption or from the PUT skipped the block that restores the screen - and
that block also held the message, so the failure was invisible as well as
permanent: the row stayed on "Saving..." with nothing to explain it.

Restoring the screen now lives in the finally, so it happens whatever the code
above did. A catch-all around the body turns any unforeseen exception into a
message and a log line, because a failure that says nothing is indistinguishable
from slowness - which is exactly how this one was reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:01:18 +01:00
r-zakarya b9a3b3e748 fix(android): saving now answers, and the editor hides what you type
Two defects, both reported from the device.

Nothing came back after Save. AdoptVault rebuilds the LIST, not the detail, so
the row stayed in edit mode: the input remained on screen and nothing said
whether the write had gone through. The detail is now redrawn from the vault in
both cases - after a success it carries the new value, after a failure the old
one, which reads without needing a sentence. It is found by UUID rather than by
index: the vault has just been replaced by the server's, and nothing guarantees
the same entry sits at the same rank.

The button also stayed inert-looking for the several seconds a save takes, so it
now says "Saving..." and disables itself while the work runs.

And the typed password showed in clear. My reasoning was that you cannot verify
it anywhere else - but that argues for a reveal button, not for leaving it
exposed the whole time. It is masked, with Show next to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:54:46 +01:00
r-zakarya f245cdcc38 perf(android): one key derivation per save, not two
Reported as "Save takes 6 to 8 seconds". A save decrypts the snapshot it just
pulled and re-encrypts the patched one - with the same password. That was two
full PBKDF2 runs at 600k iterations, four to six seconds each on this phone,
for two keys that protect the same file.

DecryptContainerEx now hands back the key, its salt and the iteration count,
and EncryptContainerWithKey reuses them. The IV is still drawn fresh every
time: reusing an IV with the same key breaks GCM entirely, whereas reusing the
salt merely yields the same key - which is the whole point. A salt slows an
attack on the password; it has no reason to differ between two versions of the
same file.

EncryptContainer keeps its old shape and now delegates, so there is a single
place that composes a container.

The log times both halves, so the next measurement says where the remaining
seconds are rather than inviting another guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:50:18 +01:00
r-zakarya 440bfd5536 feat(android): change a password from the phone, section 4 complete
Pull, patch, re-encrypt, publish - in that order, and against the SERVER's
snapshot rather than the local cache. That is what replaces the merge the plan
asked for: anything untouched is already everyone's newest version, so there is
nothing to reconcile.

The only conflict left is another device publishing between our read and our
write. Gitea refuses it on the sha with a 409, and the whole cycle repeats,
decryption included, since the content changed. Three attempts: past that the
other device is publishing in a loop and insisting would not help.

The editable field is marked in Delphi rather than recognised by its caption in
the page. A caption is prose; the day it changes, matching on it would silently
stop offering the edit.

The editor is inline rather than window.prompt: a JavaScript prompt needs a
WebChromeClient that FMX does not provide, and prompt() then returns null
without a word - the button would appear to do nothing. The value shows in
clear while typing, deliberately: you are entering a password you cannot read
back anywhere else, and masking it would mean confirming a keystroke blind.

This is the one command that carries a secret over the bridge, agreed
beforehand. It stays in the process, the iframe carrying it is destroyed, and
the handler logs the action only - never the query.

Only the password is editable. Creating or deleting an entry would need
tombstones and uuid generation, and changing a password covers the need
without either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:41:05 +01:00
r-zakarya 6bb056220e feat(android): patch the snapshot in place, never rebuild it
Section 4 step 4. TVault reads a subset of the fields, so re-serialising the
vault from what it understood would drop attachment contents, created_at, the
account avatar, tombstones, and whatever the desktop adds next - and the
desktop would adopt that amputated version on its next pull, the merge being
last-write-wins on updated_at. Silent, and final.

So nothing is rebuilt: the received JSON is edited in place and the rest is
handed back untouched. What the phone does not understand travels intact by
construction rather than by vigilance.

A corollary worth naming, because it removes the step the plan feared most:
since the patch applies to the snapshot just pulled, there is no merge to
write. Anything untouched is already the server newest version. The only
possible conflict is another device publishing between our read and our write,
which Gitea refuses on the sha.

The self-check tests what is PRESERVED, not what changes: a field unknown to
TVault, an attachment content_b64, the untouched sibling entry, the
tombstones. It reports "patch keeps what it does not understand" on the device
at every debug launch.

Also confirmed by this log: the write token now survives a restart, so the 304
settings fix holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:35:12 +01:00
r-zakarya 382e38913b feat(android): the write path works, and settings survive a 304
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>
2026-08-17 19:05:28 +01:00
r-zakarya 683655415f fix(android): locking the vault no longer discards the typed settings
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>
2026-08-17 18:52:45 +01:00
r-zakarya 90e8fabf58 feat(android): the write transport, and an error that says why
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>
2026-08-17 17:47:55 +01:00
r-zakarya ae48fadb6b feat(android): encryption, verified by a third implementation
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>
2026-08-17 17:08:34 +01:00
r-zakarya 65f5659d06 fix(android): opening the vault no longer calls the server every time
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>
2026-08-17 16:15:07 +01:00
r-zakarya f97e44768a fix(desktop): stop pushing a snapshot that changed nothing
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>
2026-08-17 15:41:44 +01:00
r-zakarya 6cab9b7c0a feat(android): Gitea read path verified on device
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>
2026-08-17 14:09:36 +01:00
r-zakarya c2f7f47eba chore(android): the Gitea contents URL becomes the first-run value
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>
2026-08-17 14:00:59 +01:00
r-zakarya 661aac744c fix(android): keep the endpoint and token once the transport works
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>
2026-08-17 13:48:54 +01:00
r-zakarya 74b20266b3 fix(android): a missing reply is not "HTTP 0"
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>
2026-08-17 13:40:53 +01:00
r-zakarya c215a5755d fix(android): a network call that never returns no longer kills the interface
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>
2026-08-17 13:11:21 +01:00
r-zakarya 77809b09c8 refactor(android): the unlock screen moves to HTML, Skia and the style go
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>
2026-08-17 10:40:06 +01:00
r-zakarya d1d5d29879 fix(android): the page stops sliding, and locking comes back
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>
2026-08-17 00:18:33 +01:00
r-zakarya 8c1cabb5bd feat(android): the list and the detail move to HTML
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>
2026-08-16 23:57:46 +01:00
r-zakarya 470598a723 fix(android): no User button on an entry without a username
A note has no identifier - the button copied nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:22:58 +01:00
r-zakarya 7218df2f6f feat(android): copy from the list, server params grouped, HTML probe
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>
2026-08-16 23:16:18 +01:00
r-zakarya e85f46f501 feat(android): copy from the list, framed entries, one radius everywhere
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>
2026-08-16 22:44:52 +01:00
r-zakarya 218a0dde03 fix(android): the search field stops moving, and the magnifier steps aside
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>
2026-08-16 22:35:30 +01:00
r-zakarya 4347dba8d9 feat(android): the system bars join the gradient
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>
2026-08-16 22:23:27 +01:00
r-zakarya 9bc005696d fix(android): a field should look recessed, a button raised
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>
2026-08-16 22:18:16 +01:00
r-zakarya 2e2ded1146 fix(android): the Copy buttons were ellipses, not pills
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>
2026-08-16 22:06:09 +01:00
r-zakarya 27a7a322d6 feat(android): the palette taken from the phone's global search
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>
2026-08-16 19:47:44 +01:00
r-zakarya 8dedcaecab fix(android): stop offering an open that cannot succeed
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>
2026-08-15 19:45:25 +01:00
r-zakarya ceaba331d8 feat(android): the search field takes the shape Android uses for search
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>
2026-08-15 19:38:37 +01:00
r-zakarya 5ef4987272 fix(android): a lock with no key locked the user out entirely
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>
2026-08-15 19:34:05 +01:00
r-zakarya 49377b0990 fix(android): the clear cross lives beside the field, not inside it
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>
2026-08-15 19:27:36 +01:00
r-zakarya aba873b9cc feat(android): follow the desktop's look, and a cross to clear the search
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>
2026-08-15 19:19:22 +01:00
r-zakarya 301a999596 feat(android): a short grace period before backgrounding locks the vault
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>
2026-08-15 18:51:57 +01:00
r-zakarya 773e47330f fix(android): ask for the device credential alone when no biometric is enrolled
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>
2026-08-15 18:35:30 +01:00
r-zakarya d472dd4ef8 fix(android): encryption cannot see a dead key, so stop pretending it can
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>
2026-08-15 18:21:50 +01:00
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