Commit Graph

622 Commits

Author SHA1 Message Date
r-zakarya 2e84c8a7f8 fix(android): the busy veil was switching the whole page off
An empty dark blue screen, which is the window's own background with nothing
drawn on it. The cause is one name used twice: the veil element carries class
`busy`, and the state was put on the body under the same name - so
`.busy { display: none }` applied to the BODY as well, and the page vanished
the moment any operation started.

The state is now called `is-busy`, which nothing else carries. Checked the
other body states - typing, selecting, searching, on-list, on-detail - none
of them names an element, so none has the same trap.
2026-08-22 00:23:34 +01:00
r-zakarya 5df1c7e8e8 feat(android): say that work is happening, on every screen
The home screen's buttons already dimmed during an operation, but from the
list nothing moved: deleting or duplicating takes a second or two - the
container's round trip - and the screen stayed identical, as if the tap had
not registered. A veil with a turning arc now covers any screen, and it also
prevents the second tap, which would not wait any better.

Its safety net is the existing 25 s deadline: even if an operation never
hands back, the veil lifts.

The plan records where deleted entries actually are: every publish is a Gitea
commit, so the snapshot from before the deletion is still in the repository's
history, readable in its web interface and recoverable by import. Not a
screen, but a real net - and one that catches what a trash would not: an
unlucky edit, a bad import.
2026-08-22 00:18:07 +01:00
r-zakarya ebd4752ec9 feat(android): a selection bar in the list, and Duplicate everywhere
The floating menu was wrong for a list. It closed on the next tap, so opening
an entry afterwards took two gestures and felt like a button had got stuck.

Android's contextual bar instead: a long press selects, the top bar takes the
place of search, says how many rows are held, and offers duplicate and
delete. It goes away when nothing is held. One does not search while choosing
what to delete, and two rows of controls would fight over the same thumb.

Selection is multiple, because the cost is in the bar, not in the work: the
patch closure loops, and ten deletions become ONE publish. The container goes
up whole on every write - deleting ten entries one by one would pay that trip
ten times, and give nine chances to fail halfway.

A tap during selection picks rather than opens, which is also the way out:
unpicking the last row closes the bar. The hardware back cancels the
selection before locking the vault - locking because someone wanted to cancel
a choice is the opposite of what the gesture means.

Both menus stay, with distinct jobs: the bar acts on rows chosen in the list,
the detail's three dots act on the entry in front of you. Duplicate copies
everything, attachments included, as the desktop does - a copy that silently
lost the attached document would be a surprise discovered too late.
2026-08-22 00:10:38 +01:00
r-zakarya 0619740da1 fix(android): delete from the list, and stop reopening what was deleted
Two things, and the second was me misreading the question: the affordance was
asked for in the LIST, not in the opened entry.

A long press on a row opens the same menu. Not a cross per row - an
irreversible action a thumb's width away on a list one scrolls - and not a
third button per row, when those were just cleared away. A long press is
invisible until used, it is Android's convention for acting on a list item,
and it is where multi-select would go if it ever comes.

And the deleted entry reopened itself. editDone hands control back after a
refusal, but it redrew the LAST detail - after a delete the view has already
returned to the list, so the entry one had just erased came back on screen.
It now hands back to what is on screen, not to what was.

Delphi deletes by rank when the list asks, by the open entry otherwise -
exactly like open and copy. The page never sends a uuid: it has one, but
letting it name a row Delphi never displayed is a door with no reason to be
open.
2026-08-21 23:51:09 +01:00
r-zakarya 00d5b582cc feat(android): delete moves into the entry's menu
It was a full-width red button at the foot of every detail, permanently in
view for a gesture one makes three times a year. It now lives behind the
three dots in the header: open, then confirm - two deliberate gestures, and
nothing lingering in sight the rest of the time.

A single item in a menu is not an oversight. The action has no way back, and
an action with no way back should not sit where a stray tap reaches it. A
cross in the corner would be worse still: that corner means "close"
everywhere else.

The plan records what the server code says about the trash: receiving a
tombstone runs DELETE FROM vault_entries, a hard delete. A deletion from the
phone removes the row on both sides and bypasses the desktop's trash. The
only net is the desktop's backups, and there is no trash to show here - the
phone never receives its contents.
2026-08-21 23:40:35 +01:00
r-zakarya a3a6d8b0f8 fix(android): a dropdown raises no keyboard, so nothing should move
Focusing the Folder field made the screen jump: every .field-in was treated
as if a keyboard were coming, reserving 60% of the height and lifting the row
to the top of the panel. A <select> opens a native list over the page - it
occupies nothing and vanishes on choice.

And the lift threshold now says what the keyboard actually covers, a little
under the bottom half. At 40% it raised fields that stayed perfectly visible,
moving under the eye what was already fine.
2026-08-21 23:29:06 +01:00
r-zakarya 1f9606fed4 docs(android): why the clipboard chip appeared late, and why the fallback cannot 2026-08-21 23:24:01 +01:00
r-zakarya d06410dd7e fix(android): emptying the clipboard is not copying into it
The white "copied" chip appearing long after the gesture was Android's, and
it was ours to stop causing: the 45 s auto-clear wrote an empty string
through setPrimaryClip, which the system reads as a copy and announces like
one. The comment claimed the visible result was the same. It is not, and the
API has distinguished emptying from copying since 2018 - clearPrimaryClip,
kept behind an API 28 check because minSdk is 23.

It only showed every other time because the timer clears only if the
clipboard still holds our text; copying anything else in between cancels it.

Nothing to fix on the sensitive flag: it is set, the chip shows no content,
and that generic wording is exactly what the flag produces.
2026-08-21 23:04:03 +01:00
r-zakarya b1f84cb977 style(android): the inline editor's buttons become icons too
Show, Cancel and Save shared their row with the input field, and three words
left barely enough room to read what one types - the very crowding that made
the row overflow in the first place. A cross and a tick read without
translation.

The tick gives way to a turning arc while saving. The word "Saving..." said
the wait was real; a motionless tick would say nothing, and it is exactly
during that second or two that one presses again.

The form's Save and Delete entry keep their words: they are not inline
controls sharing a row, and the second is the only action with no way back.
2026-08-21 22:41:55 +01:00
r-zakarya 4ba4c20dc3 docs(android): record the icon-button trap before the next one is added
With an icon inside, the tap target is the svg or one of its strokes, never
the button. Reading the attribute off the target returns null and the handler
falls through to its default - here, opening the entry instead of copying.
Half the taps worked, which is the worst kind of failure to diagnose.
2026-08-21 22:35:29 +01:00
r-zakarya 68722e301c style(android): the icon buttons lose their chip, keeping their target
Three translucent pills per list row weighed more than what they pointed at.
The drawing alone carries it.

The target stays 44 px: that is what a finger expects, and it does not need to
be visible to exist. The press feedback moves from the background to the
stroke, since there is no background left to dim.
2026-08-21 22:21:26 +01:00
r-zakarya 1dba5d392f fix(android): the tap lands on the drawing, not on the button
Copying a password or a username opened the entry instead - about half the
time, which is the worst kind of failure to diagnose. Since those buttons
carry an icon, the tap target is the <svg> or one of its strokes, so reading
the attribute off the target returned null and the handler fell through to
"open". A finger landing sometimes on the glyph and sometimes on the margin
made it look random. Both handlers now resolve to the button first.

The new-entry form gets a folder, like the editor: an entry filed from the
start saves having to move it.

And a folder can now be created, not only chosen. Filing an entry under a
name is not enough - it is `folders` that fills the desktop's column, and a
name missing from it leaves the entry in a folder nothing displays, looking
lost. The two now go together, in one place, for both editing and creation.
'All' is never registered: it is the default, not a folder.
2026-08-21 22:11:14 +01:00
r-zakarya 0badc5fcfc feat(android): folder and 2FA secret can be changed, and the buttons wear icons
The folder had no row of its own - it shared "Folder / tags" with the tags,
so neither could be touched. It is now CHOSEN from the known folders rather
than typed: a typo would file the entry under a folder nothing displays,
here or on the desktop, and it would look lost. Tags stay read-only because
they are an array in the snapshot, and writing one as a string would break
its shape for the desktop.

The 2FA secret gets its own masked row. The code has had a panel for a long
time, but it is computed and renewed every thirty seconds; the secret is what
one sets, changes and removes, and nothing allowed that from the phone.

User, Pass, Copy, Show and Edit become drawings. Four words on a list row left
little room for the entry's name and had to be re-read every time. Every icon
carries an aria-label: replacing a word with a drawing must not take the
information away from someone listening to a screen reader.
2026-08-21 21:51:42 +01:00
r-zakarya c25d68b721 docs(android): the bench credentials file stays, and why
Decided: it is outside the repository, it no longer enters a release build,
and the token it carries opens a test repository only. Keeping it saves
retyping forty characters at every reinstall of the bench.

What would warrant revocation is a token granting access to anything beyond
that repository - not this one. Recording the decision so it stops coming
back as an open item.
2026-08-21 17:51:11 +01:00
r-zakarya 2fa093d49c feat(android): a vault born here can be given a repository
Until now the two situations never met: a local vault had no repository, a
pulled vault had no local container. The day both exist, the rule "the local
container is the authority" means saves never reach the repository - and the
two diverge in silence, which is the worst outcome available.

Sync now merges them once and then deletes the local container: the
repository becomes the authority and the device behaves like any other. That
makes divergence impossible rather than unlikely. Order matters - publish
first, delete after; the reverse would lose the whole vault if the publish
failed.

The remote is the base, never the local. It carries what the phone cannot
rebuild: attachment bodies, the avatar, folder colours, and whatever the
desktop adds next. Starting from the local would amputate all of it, and the
desktop would adopt the amputated version on its next pull.

Arbitration is the desktop's, term for term: last write wins on updated_at, a
tombstone beats an older entry but not one edited after it - that one was
revived on purpose. Timestamps are ISO 8601 UTC on both sides, milliseconds
included, so lexicographic order IS chronological order.

MergeSelfCheck asserts the four rules at startup in debug. The merge never
runs against a real vault before it has run there.
2026-08-21 17:47:58 +01:00
r-zakarya 0b0f1e3fcd chore(android): section 5 - ten permissions down to three, no secrets in release
The manifest asked for fine and coarse location, camera, phone, phone state
and external storage - Delphi's defaults, none of them used. On a password
vault those are exactly the wrong signals: whoever reads the app's listing saw
a request for position and camera with no possible explanation. The list is
written by hand now.

A trap on the way: naming the substitution marker inside the comment that
explains its removal is enough to bring it back. The substitution is textual
and does not care that it lands in a comment.

The bench credentials are no longer compiled into a release - the local
include is debug-only. Verified in the binary in UTF-8 AND UTF-16: Delphi
stores strings as UTF-16, so an ASCII grep returned zero on both sides and
proved nothing.

The log leaves the home screen in release without leaving the application: a
1.5 s press on the status line brings it back. Nothing secret is in it, but a
permanent Log button is development noise, and diagnostics must stay reachable
for the day something goes wrong remotely.

And the Release configuration did not compile at all, which nobody knew: the
two self-checks had their declarations under IFDEF DEBUG and their bodies
outside. Invisible as long as one only ever builds Debug.

Signing is what remains, and the keystore password is the user's to hold.
2026-08-21 17:38:46 +01:00
r-zakarya eb9999c4b2 docs(android): the conflict path verified end to end, section 4 is closed
Conflict detected, fresh snapshot pulled, the change applied ON TOP of it,
publish accepted. Nothing clobbered - which is the whole point of dropping
the pre-publish fetch.

The second attempt also derived the key again, 4397 ms: the desktop had
re-encrypted its container with a fresh salt, so the key in hand no longer
fitted. That is exactly the case the reuse guards against, seen firing for
the first time. It costs one derivation per desktop publish, no more.
2026-08-21 17:26:24 +01:00
r-zakarya 30b2413ea3 fix(android): a conflict answers 422, not 409
The retry path finally ran, and did not recognise what it was handed. A
desktop publishing between our read and our write earns a 422, not a 409, so
the save failed instead of retrying - which is exactly what a never-exercised
path hides: it was written, it compiled, it was even commented, and it did
not fire.

Gitea answers 409 when the sha no longer matches and 422 when it is missing
or the request is judged unprocessable, "file already exists" among them.
Both say the same thing to us: the sha is stale. Re-reading and retrying is
right for both and cannot clobber anything - the retry publishes against the
fresh sha. A genuinely malformed body would fail again and give up after
three tries, carrying Gitea's own words.

The log now prints which code it was, rather than assuming.
2026-08-21 16:56:48 +01:00
r-zakarya 31bf4f920c docs(android): the write path confirmed from the other end
The desktop, after syncing: pulled 4 new, 1 updated, 1 deleted, pushed 11
entries. Create, edit and delete in one line, seen from the side that did not
perform them. The deletion is the one that mattered - vanishing locally proved
nothing, the tombstone had to travel.

Steps 1 to 3 of section 7 are closed. The 409 is what remains.
2026-08-21 16:49:08 +01:00
r-zakarya 795f07fd8a fix(android): Cancel works, because lastDetail is now written
It was introduced with two readers and no writer. Cancel called
renderDetail(null), which threw on d.rows and did nothing at all - and
editDone, which uses it to hand control back after a refusal, was dead the
same way. That is why the successive fixes for "Cancel does not respond"
changed nothing: they all ended in the same silent throw.

Assigned where a detail is drawn, which is the one place that knows it.
2026-08-21 16:38:26 +01:00
r-zakarya 5522089939 fix(android): the unlock code no longer changes by itself
It went from 0000 to 00000000 with nobody deciding it. The typed code and the
code the cache is sealed under were the same variable, so every write of the
cache re-sealed under whatever sat in the field - a sync, which asks for no
code at all, was enough to change it, silently.

They are two things now. Only opening the vault touches the seal, because
that is the one moment we know the current code is known: the decryption just
proved it. Everything else - sync, background refresh, saving an entry -
rewrites the cache under the code already in place.

Hence a second field, shown only once a code exists: the current one must open
the vault for the new one to take. Without it this fix would have locked the
device under the code set by accident, which is exactly the harm it is meant
to prevent.
2026-08-21 16:30:57 +01:00
r-zakarya 5107ebf7b2 feat(android): an entry can be deleted from the phone
Both, or nothing: the row leaves the snapshot AND a tombstone takes its
place. Removing the row alone does not delete - the desktop still holds its
own copy, and the next pull would bring it back as an entry we never had.
The time matters too: the desktop's arbitration keeps an entry edited AFTER
the deletion rather than killing it a second time.

No trash on the phone, deliberately. The desktop's is a `deleted` flag that
does not travel in sync, so a deletion stopping there would propagate
nowhere. Hence a button at the very bottom, in two taps, that disarms itself
after four seconds - a button left armed gets touched by accident much
later, and this is the one action with no way back.

The confirmation is in the page because the engine cannot offer one: a
JavaScript prompt needs a WebChromeClient FMX does not provide.

Not exercised on the device: it would have destroyed a real entry.
2026-08-21 16:21:45 +01:00
r-zakarya 7a0ae6398b docs(android): the save times as observed in use, not just in one log 2026-08-21 16:16:07 +01:00
r-zakarya 732c52a64e feat(android): every field of an entry can be changed, not just the password
PatchEntryField already took a field name; what was missing was carrying it
from the row. It travels in the detail row itself rather than being guessed
from the label - a caption is prose, and the day it changes the page would
send a field name that does not exist, or quietly stop offering the edit.

Delphi does not take it on trust either: four names accepted, the rest
refused. The snapshot also carries tombstones, attachments and timestamps
that PatchEntryField would overwrite without understanding them.

An empty row now shows IF it can be filled - a missing username you cannot
add is a dead end. Emptying is allowed, except on a password, where it would
almost always be a slip; the refusal says so rather than doing nothing.

Show only appears on a masked field: on a site in clear it has nothing to
reveal, and would in fact hide it. A note keeps only Name and Content - a
site and a username mean nothing there.
2026-08-21 16:13:35 +01:00
r-zakarya 4785dc116a docs(android): note what is left of the save time, and what it would cost
Deferred, with the numbers rather than an impression. The 2.7 seconds left
are entirely the upload, sent twice-encoded because Gitea's contents API
wants base64 inside JSON and the container already is base64. One 116 Ko
attachment is about 78% of what travels on every write.

Three ways out, cheapest first, including the one that needs no code.
2026-08-21 15:57:22 +01:00
r-zakarya 131bf44b36 chore(android): drop the keyboard diagnostic, and the field only jumps if it must
The trace has served: it named the cause where two rounds of reasoning had
missed it. Out with it, and out with the viewport measurement it proved
worthless here - a value that is always zero reads as a mechanism, and the
next person would trust it.

adjustResize goes too. Measured to do nothing: Android ignores the mode for a
fullscreen window, which FMX's is. Leaving it in the manifest would suggest it
carries something.

A field already near the top of the panel - the settings ones - stays where it
is. It was visible as it was, and lifting it only moved under the eye what was
already fine.

The plan keeps what the measurement cost: on this screen, instrument first.
2026-08-21 15:46:06 +01:00
r-zakarya ad8a037e29 fix(android): room under the form while typing, measured by nobody
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.
2026-08-21 15:40:58 +01:00
r-zakarya 7ee2feba1b fix(android): the window shrinks for the keyboard, so forms can scroll
Measured, after two fixes placed by reasoning changed nothing:

  focus: inner 719 visual 719 | box client 659 scroll 659 | body=on-detail

Keyboard up, and both heights unchanged: the page has no way to learn the
keyboard exists - visualViewport reports nothing here either. And the box
shows 659 for 659 of content, so there is no overflow and nothing can
scroll. The keyboard simply covers the bottom of a page that believes it
still has the whole screen.

So the remedy was never in the page. windowSoftInputMode="adjustResize"
makes the window itself shrink; the scroll box then gets real overflow and
the engine can bring the focused field into view on its own.

The reserve added below the scroll area stays: it costs nothing when the
window does resize (the measure is then zero), and it is the honest place
for the value should a device ever report it.
2026-08-21 15:32:19 +01:00
r-zakarya 79a6625613 chore(android): make the page say what it measures about the keyboard
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.
2026-08-21 15:21:10 +01:00
r-zakarya bde22d2288 fix(android): the keyboard no longer buries the bottom of a form
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.
2026-08-21 15:16:12 +01:00
r-zakarya bb222e1903 perf(android): saving no longer pulls the vault it already holds
The crypto is down to about 135 ms, so the wait was never the crypto: the
log says HTTP 200 in 3244 ms for 266299 bytes. Every save fetched the whole
vault, decrypted it, patched it, and sent it back.

The fetch existed to be sure of modifying the latest published version. The
sha proves that better and for free: publish against the snapshot in hand,
and if another device published in between, Gitea refuses with a 409 - then,
and only then, pull for real. The retry loop was already written for exactly
that.

The snapshot kept is the one RECEIVED, never a re-serialisation of TVault:
that would drop everything the phone does not read - attachment bodies, the
avatar, tombstones - which is the invariant PMA.VaultPatch exists for. It
goes at lock, with the rest of the plaintext.

Note this makes the 409 path load-bearing, and it has still never run. It is
now what guarantees a save cannot clobber another device.

The PUT still carries the whole container; that is inherent to the format.
2026-08-21 15:00:46 +01:00
r-zakarya 527a953b2a docs(android): the derivation is measured, 4395 ms down to 48
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.
2026-08-21 14:57:13 +01:00
r-zakarya 9a32fe8881 perf(android): the derived key is kept in the cache
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.
2026-08-21 14:54:05 +01:00
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 b1be802e22 docs(android): section 7 - a local account, so the app serves on its own 2026-08-21 13:55:13 +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 b7125a7373 docs(android): section 4 - the interface, set straight 2026-08-21 12:49: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 7a9fe4ae77 fix(android): nothing slides sideways any more
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.
2026-08-21 12:34:09 +01:00
r-zakarya 75fde64c20 fix(android): the detail panel can no longer be dragged sideways
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.
2026-08-21 12:30:42 +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 3e020acf24 docs(android): section 4 verified on device
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>
2026-08-17 20:35:57 +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