adjustResize changed nothing either: FMX's window is fullscreen, and Android
ignores the mode there. So three sources were asked what the keyboard covers
- visualViewport, innerHeight, the window itself - and all three answer as if
it were not there.
Stop asking. While a field has focus the panel reserves 60% of the height
below its content; that is overflow, so it scrolls, and the focused field is
lifted to the top of the panel where no keyboard can reach it whatever its
size. The reserve goes at the first field left, so nothing floats when one is
not typing.
Crude on purpose. Every attempt at measuring this correctly has been wrong,
and the honest version of "we cannot know" is to make the answer not matter.
Two fixes placed by reasoning changed nothing observable, which means the
reasoning was wrong somewhere I cannot see from here. This writes the
numbers into the log rather than a fleeting message, so they can be read
after the fact: the window height, the visible height, and - the one that
settles it - what the scroll box shows against what it contains. Equal, and
there is nothing to scroll, which would be the whole problem.
Temporary. It comes out once the cause is known.
The frame is nailed to the whole screen, so when the keyboard rises the
content overflows nothing and the bottom third becomes unreachable: the
form's password field could only be seen by folding the keyboard away.
Reserving below the scroll area exactly the height the keyboard covers
gives the panel something to scroll. The measure answers correctly whether
or not the engine actually resizes the window - if it does, innerHeight
shrinks too, the reserve is zero, and the natural overflow already does the
work. No need to know which case applies.
A first attempt shrank the body to the visible height; it changed nothing
observable, and it depended on which of three over-constrained box
properties the engine drops. This does not.
The plan records where a save's time actually goes: 46 ms of crypto, 2745 ms
of PUT for 267 Ko. That is the floor for a monolithic container.
Confirmed on the device where reading could not: a real save prints
"opened with the key already in hand, in 48 ms". What remains in the wait
is the GET and the PUT, nothing computed.
Opening from the cache derives nothing, so the key learned last session was
gone and the FIRST save of every session paid the 4.5 seconds again. It now
travels in the sealed blob, next to the plaintext vault and to the password
that opens the container - that neighbourhood is what makes it acceptable:
it exposes nothing the file does not already hold.
The three parts go together or not at all. A key without its salt does not
say what it fits, which makes it useless, or worse, used where it does not
belong. A cache written by an earlier version carries none: LoadVault
returns nil, one derivation happens, and the next write stores it.
A freshly registered vault still derives once - EncryptContainer does not
hand back the key it just derived.
The plan records what the desktop confirmed: the two entries created on the
phone imported with nothing to correct.
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.
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.
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.
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.
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.
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.
The previous attempt clamped the scrolling panes, and the panel still
slid. It was not the panes: overflow:hidden forbids the GESTURE, not the
engine, which keeps the right to move a box so the newly focused field
comes into view - and the rail's off-screen views, parked at
translateX(100%), leave it room to the right. Confirmed by measurement:
the CSS was in the shipped library and the behaviour was unchanged.
Caught in the capture phase now, whichever box moves and whatever moves
it. No view here has content to the right, except the folder strip.
The editing row kept the full-width field and set three buttons beside
it, so the line was wider than the frame. A pane scrollable on one axis
is scrollable on both - the specification turns the untouched axis into
`auto` - so the overflow was draggable, and the keyboard's Enter key
brought the field back into view, taking the whole panel with it.
The field now shares its line, and no pane scrolls sideways. Enter
validates, which is what the key was pressed for.
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>
save: attempt 1 - GET
save: decrypt+patch in 4519 ms
save: re-encrypt in 23 ms
save: published, new sha 802515d4...
The phone writes to the vault. The 23 ms measure the reused key: the second
derivation was worth the same 4.5 seconds as the first.
Three things are recorded as open rather than left to be rediscovered: the 409
retry is written but has never run, there is no offline editing (the phone
patches the snapshot it just pulled, so Save needs the network - that is the
price of having no merge to write), and only one field is editable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
It said "gitea token", which is wrong for the other transport this client
speaks: the same field carries a Bearer token to the PHP endpoint. The URL
decides which - naming the field after one of them makes it false for the
other.
Worth stating plainly while here: this client does NOT do WebDAV. There is no
Basic auth in PMA.SyncClient and no username field; only the desktop still
speaks it. Supporting it here would need both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The labels went in this afternoon to tell the token and the sync password
apart, both being masked and stacked. They are removed on request; the
placeholders carry that job again, which they do while the fields are empty -
and that is when it matters, since a filled field is one you just typed.
Each field gets a cross instead. Clearing a forty-character token with the
backspace key takes forty presses and leaves a character behind easily, which
shows up as a 401 whose cause is invisible.
The cross is a SIBLING of the input, laid over it - never a child. A child of
an input does not receive the tap; that cost a build earlier today when the
search field was given its own cross.
It also pushes the configuration immediately: the field keeps the focus, so the
blur that normally carries the value to Delphi never happens.
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>
Kept in settings.bin, because the silent refresh needs it and so does the
first download on a fresh device - there is no vault to hide it in yet. But it
was the only secret on that screen shown in clear, right beside a masked
password field, and it is also the least protected of the three: settings.bin
is sealed by the no-auth Keystore key, readable without authentication, unlike
the vault.
Masked now. It is typed once, and reading it back has no use - a wrong token
shows up as a 401 in the log, not by squinting at the field.
What it opens stays worth knowing: read access to the account's repositories,
whose only content is ciphertext.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified on device: opening the vault no longer calls the server on every
open. The throttle works.
What it does not do is explain the underlying defect, so the plan now carries
it as an open item with the measurement, what has been ruled out (the Gitea
transport is selected, SaveVault stores what it is given, vault.bin is
rewritten, the container decrypts), and the distinction between worked around
and fixed. Two fingerprints print identically and compare unequal; only an
invisible character explains that, and reading the path did not find it.
Written down rather than left in a commit message, because the cost is now
paid once a quarter of an hour instead of on every open - which is exactly the
kind of defect that stops being noticed before it stops existing.
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>
The translucent circle exists to back the initials of an entry without an
icon. A favicon brings its own transparency, so the circle showed through and
around it - a saucer under the drawing, plainly visible on the Gitea cup.
The circle is dropped as soon as the row carries an image, through a class on
the avatar rather than :has() so it does not depend on how new the engine is.
object-fit also goes from cover to contain: an icon is not a photograph and
has no business being cropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The full round trip ran: the desktop pushes (second commit in pm_sync), the
phone reads what it published. The proof was one entry - "gitea token read",
created on the desktop, appeared on the phone without touching sync.php or
WebDAV, and the trash did not travel, which is what buildSyncSnapshot intends.
Recorded with it are the three details that would cost an evening to
rediscover: sha omitted rather than empty on a first push, branch omitted so
the repo default is used, and the desktop token needing write:repository while
the phone keeps read.
Also recorded, though unrelated to sync: settings sections are mapped to tabs
by their visible label text, so renaming one moves the section to the wrong tab.
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>
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 plan said to create a personal token "limited to this repo". Gitea has
no such thing - checked against 1.26.1: its scopes are per category and
account-wide, so read:repository grants read on every repo the account can
read. That is not GitHub's fine-grained model, and the difference changes
what you actually do.
So the section now says what is available instead, cheapest first:
read:repository alone while the phone only reads, a separate token on the PC
when publishing lands, and a dedicated Gitea account as collaborator if real
isolation is ever needed.
Also recorded: the contents endpoint answers 404 unauthenticated on a
private repo, which is the same snapshot-absent-or-no-access ambiguity the
PHP endpoint has, and the client already treats it as "nothing to download".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PMA.SyncClient now speaks two transports, told apart by the URL:
/api/v1/repos/ means the Gitea contents API, anything else the old PHP
endpoint. The PHP path stays until the Windows side publishes to the repo
too - without it the migration would need both ends flipped at once, with
no way back.
Three things differ on the Gitea side and nothing else: the header is
"token <PAT>" not "Bearer" (swapping them gives a silent 401), the body is
JSON carrying base64 content, and the fingerprint comparison happens in the
client since the API has no If-None-Match. The body is re-downloaded, which
is cheap; the four-second key derivation is still avoided. The result's
ETag field carries the sha, so the caller cannot tell which transport
answered.
usesCleartextTraffic is replaced by a network config that permits cleartext
to 127.0.0.1 and localhost BY NAME and forbids it everywhere else. The
attribute drew no such line: a mistyped URL sent the bearer token over the
network in the clear with nothing to signal it. Now it fails immediately,
which is the behaviour worth having.
Verified in the built APK: res/xml/network_security_config.xml is present
and the manifest attribute resolves to a real resource id. NOT verified
against a real repo - that needs a private repo, a published
vault-sync.json and a token, none of which exist yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 3 bis asked whether Delphi controls were needed at all and said to decide before investing further in the interface. It is decided, so it now records the answer and the two traps buried in the bridge - the iframe, and the page having to stay ASCII - rather than leaving them to be re-discovered.
Section 1 ter described Skia as the fix for Arabic; it is gone, and what survives of that episode is the packaging constraint, which still governs the build.
The table in section 0 claimed template, icons, attachments, timestamps and folders were unread. They are read now; it says which parts, and what is genuinely still missing.
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 padlock was drawn in thin blue strokes on slate: at 36 px the outline muddied and the shackle vanished. A filled white shield with a punched keyhole keeps its silhouette at every size, and says protection more plainly.
The keyhole is CUT OUT rather than painted, through an even-odd fill on a single path - painting it meant keeping two files agreeing on one colour, and any drift would have read as a smudge. The launch layer is the exception: it sits flat on the system background, where a cut-out would be a hole, so there the keyhole is painted and stays entirely inside the white shield.
The launch background takes the top of the app gradient instead of slate, so the launch screen dissolves into the first screen rather than cutting against it. No gradient inside the vectors: the aapt this chain uses does not know inline gradients.
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 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>
A radius of 24 kept them fully rounded even after the fill was hollowed out, so a text field still read as a command. They now take 14, the same as the row buttons - rounded, unmistakably rectangular.
The search field loses the stadium shape of Android's global search along with the others, since one style serves every edit. Its magnifier is what identifies it now. Giving it back its own shape means cloning the style entry, which is worth doing only if the shape is missed.
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>
The Contents API maps term for term onto what is already written: it returns the file with its sha and demands that sha to write, answering 409 on a stale one - exactly the roles of the ETag and the 412. The desktop's bounded reread-remerge-repush loop would work unchanged.
It also settles the one remaining obstacle: nothing to host, no certificate, outside access for free. Records the calls, and why the answer is a private Gitea rather than GitHub - git history never forgets, so every past snapshot stays readable under whatever sync password was current then. Survivable on your own disk, not on a third party's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The theme still is not the one from the global search - the blue and the pill were taken, not the spacing or the density. And a larger one: whether there should be Delphi controls at all, when the desktop shows no native controls either and serves the same HTML in a WebView.
Worth deciding before investing further in the current interface: every display fix made today would be thrown away.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cyan came from the desktop app. On the phone the reference is the system: its global search and its own apps are blue, and an app should look like the place it runs.
Accent #3B82F6 on the same slate, with white on the accent since blue is darker than cyan. The launcher icon and the launch screen follow - a cyan padlock in front of a blue interface would have read as a different app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>