Commit Graph

39 Commits

Author SHA1 Message Date
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 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 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 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 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 b7125a7373 docs(android): section 4 - the interface, set straight 2026-08-21 12:49:48 +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 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 60e3cae3e4 docs(android): record the sha comparison as open, not solved
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>
2026-08-17 16:18:30 +01:00
r-zakarya 930f74147f docs: section 6 is done - both ends sync through Gitea
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>
2026-08-17 15:20:11 +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 0bcf9b95a2 docs(android): a Gitea token cannot be limited to one repo
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>
2026-08-17 12:42:33 +01:00
r-zakarya 68df2dd7e7 feat(android): read the snapshot from a Gitea repo, and close cleartext
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>
2026-08-17 11:55:24 +01:00
r-zakarya b3fbaf44bd docs(android): the plan catches up with the HTML interface
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>
2026-08-17 10:53:02 +01:00
r-zakarya 1609b4d328 docs(android): Gitea as the transport, instead of hosting the endpoint
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>
2026-08-16 11:36:43 +01:00
r-zakarya 40475b0204 docs(android): two open questions on the interface
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>
2026-08-15 20:00:04 +01:00
r-zakarya fa424a3bc7 docs(android): section 1 is complete, six checks out of six
Removing the screen lock permanently invalidates every Keystore key that requires authentication, so the cache was unreadable for good and re-downloading was the only way out. The automatic open kept the cache and reported; only the explicit tap cleared it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:03:09 +01:00
r-zakarya b5ccf63a85 feat(android): Skia renders the text, so Arabic reads properly
An Arabic attachment name displayed scrambled. Not a missed setting: FMX's original text engine does not shape complex scripts - no bidirectional reordering, no letter joining. Separating the size from the name limited the damage without removing it. Skia carries HarfBuzz and does the work.

GlobalUseSkia goes before Application.Initialize; the canvas is chosen at init and switching later does not reclaim what is already built. The price is libsk4d.so, 25 MB raw, and an APK from 13 to 22 MB.

Deploy rebuilds library\\lib just before calling aapt, so a hand-placed library disappears. A proper DeployFile entry was tried first - MSBuild ignores it, as it ignores every other one in this project. Hence repack-apk.ps1, which redoes aapt, zipalign and signing on the folder already prepared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:44:43 +01:00
r-zakarya 861d721513 docs(android): plan caught up with what the device confirmed 2026-08-15 15:23:04 +01:00
r-zakarya 7229028060 feat(android): show attachments, without carrying their bytes
One attachment took the cache from 13 to 174 KB: the content travels base64 in the snapshot and is decrypted in memory with the rest. TVault reads name, type and size and DROPS content_b64 - holding it for the whole vault while nothing displays it would be paying for nothing.

The detail now says a document exists. Saving it is what remains, and what blocks that is not code but where a file lands on Android 10+. When that is settled, the content re-reads from the raw JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:05:08 +01:00
r-zakarya 7939d268f3 feat(android): mark the clipboard sensitive, and lock after 3 min idle
Since Android 13 the system shows a preview of whatever was just copied, at the bottom of the screen. For a password that is precisely what we are trying to avoid: it appears in clear, outside the app, for several seconds. The flag that suppresses it is a ClipDescription extra, which IFMXClipboardService does not expose - hence going straight to ClipboardManager. Note Jcontent_ClipboardManager, not JClipboardManager: the latter is the deprecated android.text one, which knows only bare text and has no ClipData to carry an extra.

Backgrounding already locks; the idle timer covers the phone left face-up on a table with the app open. It samples timestamps every 15 s rather than counting seconds - a wake-up per second would cost battery for no precision that matters.

Attachments dropped from the plan: this vault has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:58:27 +01:00
r-zakarya 0e39cf7e56 docs(android): the expired auth window no longer costs the cache 2026-08-15 14:37:56 +01:00
r-zakarya f3a8631440 docs(android): the cache chain is verified on device
Write with no prompt, read under the device credential, cache surviving a cold start with no derivation, and a silent 304. Records the three traps that hid behind it, none of which the compiler could see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:28:58 +01:00
r-zakarya 09048410e1 fix(android): report the biometric failure instead of destroying the cache
The launch-time auto-open received boUnavailable and wiped the cache, seconds after the write path had proved the device could authenticate. The diagnosis was wrong and the reaction irreversible. An automatic action now never clears the vault: it logs the cause and leaves the user on the unlock screen. Only an explicit tap on Open, followed by a clean refusal from the system, may clear it. PMA.Biometric keeps the reason in LastBiometricError, and a Java-layer exception now maps to boFailed rather than boUnavailable - an exception says nothing about the device. The log memo also wraps: it was cutting off exactly the end of the message, which is where the cause is named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:42:07 +01:00
r-zakarya 9a4170c44f feat(android): folder filter, icons, sort, template, updated_at, FLAG_SECURE
Works through the plan's §2 and §3 items that need no hardware and no
platform decision.

- allowBackup="false". The private directory holds vault.bin; a cloud
  backup has no business in a password manager's path, even though what
  would leave is encrypted by a key that stays in the chip.
- FLAG_SECURE, Release builds only - in debug it also blocks development
  screenshots. Complementary to hiding on WillBecomeInactive rather than
  redundant: that one covers the recents preview, the flag also covers
  manual captures, screen recording and casting to an external display.
- List sorted by name (snapshot order is DB order, arbitrary to the eye),
  entry icons decoded once per vault rather than on every keystroke, and a
  folder dropdown whose selection survives a background refresh. No tag
  filter: text search already matches tags.
- template and updated_at read and shown in the detail. updated_at is also
  step 1 of the write path - nothing is arbitrable without it.

Android code is now type-checked without deploying: dccaarm64 reaches the
link step and fails only on the NDK's ldandroid.exe. A JNI declaration that
does not compile no longer waits for an APK to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:23:29 +01:00
r-zakarya 3bb6f6f903 feat(android): show first, sync after
The local vault now opens by itself at launch and the GET runs behind it,
silently. A 304 shows nothing; a network failure shows nothing either -
offline is a normal state, and an alert on every launch only teaches people
to dismiss alerts. Only a new snapshot manifests, by refreshing the list.

That silence is what puts the sync password inside the Keystore-encrypted
file next to the vault: without it a new snapshot cannot be opened, so every
edit made on the desktop would raise a password prompt at launch. The file
already holds every password in plaintext; this one adds almost nothing to
its exposure. A typed password still wins over the stored one, which is how
you fix one that no longer works.

Adopting a snapshot while the app is already open rebuilds the list in place
instead of navigating - mandatory even from the detail screen, since list
rows carry indices into the old vault and a tap would open the wrong entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:14:44 +01:00
r-zakarya ba0d3c1d3c docs(android): plan of the remaining work 2026-08-15 10:56:49 +01:00