Commit Graph

124 Commits

Author SHA1 Message Date
r-zakarya 23568049da feat(android): saisir le secret 2FA des la creation d'une entree
Le formulaire New entry n'avait pas le champ et AddEntry ecrivait
totp_secret = '' en dur : il fallait enregistrer l'entree puis la rouvrir
pour poser un second facteur, alors que l'editeur portait deja le champ.

Meme intitule que l'editeur ("2FA secret"), masque pour une note comme
site et identifiant, et achemine par &o= — t, s, u, p, k, d etaient
pris.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:18:39 +01:00
r-zakarya cbcdb3f593 fix(android): le temoin bleu sait enfin s'eteindre
FRemoteAhead etait remis a faux a trois endroits - publication,
publication differee, rafraichissement - et aucun ne le disait a la page.
Seul l'allumage etait pousse. Le point bleu restait donc affiche apres la
synchronisation qui venait de le rendre caduc, jusqu'au retour suivant
sur l'accueil.

Un temoin qui reste allume apres qu'on a fait ce qu'il demandait ne
demande plus rien : il decore, et on apprend a ne plus le regarder.

Le drapeau et son affichage passent maintenant par SetRemoteAhead, dans
les deux sens. Meme asymetrie, meme correction que pour le point orange.

PROBE_EVERY_TICKS revient a 40 : la bascule a 8 n'etait que pour rendre
la serie E observable en deux minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:27:40 +01:00
r-zakarya b2c677997e fix(android): un faux echec de synchronisation, et des boutons colles
Le chien de garde mesurait la duree TOTALE, alors que son message dit
"no reply from the server". Sur un coffre devenu gros - 286 Ko - la
reponse arrivait en 841 ms et les vingt secondes suivantes partaient dans
la derivation de la cle puis l'affichage : il criait donc a l'absence de
reponse alors que tout avait reussi, cache ecrit compris. Un faux echec
est pire qu'un vrai, il apprend a se mefier de ce que l'application dit.

Il est desarme des que le statut HTTP est connu, quel qu'il soit : ce qui
suit ne depend plus du reseau, et le voile est leve par le finally.

Au passage, les boutons des reglages n'avaient pas de margin-top : le
champ du code et ses deux boutons se touchaient et se lisaient comme un
seul bloc. Les piles de l'accueil ont leur gap ; ici les elements se
suivent en flux normal, donc c'est au bouton de reserver sa place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:22:45 +01:00
r-zakarya 363b934e8a fix(android): le temoin d'attente parait des l'accueil, sans ouvrir le coffre
FPending est relu au demarrage, mais PM.setPending n'etait pousse que par
OpenLocalNow : la page n'apprenait donc l'attente qu'en OUVRANT le
coffre. Un demarrage a froid montrait Sync sans point orange - l'ecran ou
l'on decide de synchroniser, et le seul moment ou ce drapeau relu au
demarrage sert a quelque chose. Il fallait ouvrir puis reverrouiller pour
qu'il paraisse.

Les deux temoins se reposent maintenant dans ShowUnlock, donc a chaque
retour sur l'accueil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:31:27 +01:00
r-zakarya 85691bb2f1 feat(android): un bouton pour jeter le coffre local, en debogage seulement
Il vit en bas des reglages, apres tout le reste, et demande deux appuis :
un effacement sans retour ne se declenche pas sur un doigt qui glisse.

Absent en release, et pas seulement cache : la commande du pont n'est pas
compilee et Delphi ne devoile la section que dans une version de
debogage. Le bouton ne peut donc pas reapparaitre par accident. Verifie -
Release compte 8060 lignes contre 8072 en debug, soit exactement ce bloc.

DropLocalVault efface le cache, le conteneur local et le drapeau
d'attente, chacun sous son propre try : un fichier absent ou verrouille
ne doit pas empecher les deux autres de partir. Les reglages du depot
restent - c'est le coffre qu'on jette, pas la facon d'aller le rechercher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:47:53 +01:00
r-zakarya d428a6fc7f feat(android): un coffre telecharge n'est plus lisible sans rien demander
Sans biometrie utilisable et sans code pose, le cache s'ouvrait sans rien
demander - or il CONTIENT le mot de passe de sync. Qui tenait le
telephone deverrouille obtenait donc le coffre ET le secret du depot.

Il est desormais scelle sous le mot de passe qui vient de le rapporter :
celui-ci est deja dans ce fichier, donc rien de neuf n'est expose, et
l'utilisateur le connait puisqu'il vient de le taper. C'est aussi la
seule forme de cache qui survive a une reinitialisation du verrouillage
d'ecran.

Pas quand l'appareil sait authentifier : VER_PIN et VER_WRAP s'excluent,
et imposer une frappe a qui a une empreinte serait un recul.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:25:16 +01:00
r-zakarya 5cc80727d6 fix(android): l'exception etait lue apres sa liberation
Register et Login mettaient leur gestionnaire d'erreur en file avec
TThread.Queue, puis y lisaient E.ClassName et E.Message. Or la file
s'execute sur le thread principal bien apres que le except a rendu la
main, et Delphi libere E en sortant du gestionnaire : c'etait une lecture
apres liberation.

Elle rendait un message vide - "Login failed: EPMACrypto - " sans la
raison - et parfois une violation d'acces qui remontait a FMX sans rien
laisser au journal. Le vrai motif de l'echec n'a donc jamais ete affiche.

On ne met plus en file que des chaines, qui survivent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:46:24 +01:00
r-zakarya 4e0223bee5 fix(android): aucune exception ne sort du pont sans laisser son nom
Le dispatch n'etait garde par rien : une faute dans un handler remontait
jusqu'a FMX, qui affichait "Access violation" avec une adresse, tandis
que le journal ne montrait que la commande recue suivie de rien. On
savait qu'une commande echouait, jamais laquelle ni pourquoi.

On attrape, on nomme, et on rend la main - une commande fautive ne doit
pas laisser l'interface voilee derriere une boite de dialogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:39:10 +01:00
r-zakarya a41c61405c fix(android): retirer le code de deverrouillage demande de le connaitre
Le coffre ouvert ne prouvait rien : il peut l'etre depuis une heure et
rester a l'ecran. Quiconque trouvait le telephone dans cet etat levait la
protection en deux appuis, sans que rien ne le lui demande.

Le code courant voyage maintenant avec la demande, et Delphi le compare a
FPinSealed - ce sous quoi le cache est REELLEMENT scelle, pose seulement
par une ouverture reussie, jamais par un champ.

Fermee au passage : SetUnlockCode('') retirait le code en silence, ce qui
contournait le controle qu'on vient de poser.

Le FBusy de ClearUnlockCode ne sort plus sans un mot - une sortie muette
se lit comme une reussite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:30:30 +01:00
r-zakarya 203f45eb7c fix(android): les refus se voient, et la croix se recale sur le champ
Un refus s'affichait du meme gris qu'un etat : "Master password: 8
characters at least" se lisait comme "Vault on this device", et rien ne
disait que l'application venait de refuser. StatusErr le met en rouge -
deux procedures plutot qu'un drapeau devine du texte, parce que trier des
messages sur leur contenu a deja detruit un cache dans ce projet. C'est
l'appelant qui sait s'il refuse.

La croix des champs etait calee a top: 8px, en compensation du margin-top
du champ. Les piles annulent ce margin depuis qu'on y a unifie
l'espacement, et la croix restait donc 8 px trop bas. Elle s'ancre
desormais au bord INFERIEUR, le seul que le champ touche dans les deux
cas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:25:07 +01:00
r-zakarya 494efddb44 feat(android): le mot de passe de creation ouvre le coffre
Il ne servait qu'une fois, a la creation : le cache s'ouvrait ensuite
sans rien demander, et n'importe qui tenant le telephone deverrouille
lisait le coffre.

Il est desormais pose comme code de deverrouillage - c'est exactement ce
que ce format sait faire, et il accepte un mot de passe entier aussi bien
que des chiffres depuis qu'on a retire le clavier numerique. Le cache
porte donc VER_PIN, la seule forme qui survive aussi a une
reinitialisation du verrouillage d'ecran.

Retirable en deux appuis depuis les reglages, pour qui prefere
l'ouverture immediate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:14:54 +01:00
r-zakarya 9b26a1dbd0 fix(android): un coffre vide n'est pas un coffre ferme
"Coffre ouvert" se lisait sur le nombre d'entrees. Or un coffre vide en
compte zero, exactement comme un coffre ferme - et le coffre d'essai
l'est. Le garde de DoSync restait donc vrai apres l'ouverture, rearmait
la reprise, et l'on tournait en rond : le journal repetait "Vault opened
from cache", l'interface clignotait et ne repondait plus.

Le meme idiome se trouvait a deux autres endroits. Le second compte :
OnIdleTimer se coupait de lui-meme sur un coffre vide, qui restait donc
ouvert indefiniment - et vide aujourd'hui ne veut pas dire vide dans dix
minutes.

FPlain, le clair en main, est le seul signal qui ne mente pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:12:20 +01:00
r-zakarya af28788a0a fix(android): Sync ouvre le coffre des qu'il y en a un, pas seulement sous code
Le garde ne couvrait que le cache scelle par un code. Depuis que les deux
secrets sont separes, la reconciliation a besoin de celui du conteneur
local - qui ne se pose qu'a l'ouverture - donc un coffre ne ici refusait
aussi, avec "open vault first", et ouvrir ensuite ne relancait rien : le
bouton Sync ne vit que sur l'accueil, et le coffre ouvert on est sur la
liste.

Un seul garde desormais : s'il y a un cache lisible et que le coffre
n'est pas ouvert, on ouvre et on reprend la synchronisation. Un appareil
sans cache n'est pas concerne - c'est le premier telechargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:07:25 +01:00
r-zakarya dd44a164f6 fix(android): le cache retient le secret qui ouvre CE coffre
La separation laissait FVaultPwd vide apres une ouverture depuis le
cache - seules la creation et la connexion le posaient - et la
reconciliation refusait donc en boucle, "open vault first" alors que le
coffre etait ouvert.

Le cache porte un secret et un seul : celui du conteneur local pour un
coffre ne ici, celui du depot pour un coffre publie. Les deux ne
coexistent jamais, puisque la reconciliation detruit le conteneur local
en passant l'autorite au depot. CachePwd choisit a l'ecriture, et la
relecture le range dans la meme case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:04:04 +01:00
r-zakarya ed7ccdd2d6 feat(android): deux secrets separes, comme sur le poste
Le mot de passe de creation scellait le conteneur local ET pretendait
sceller l'instantane du depot. Un coffre cree ici publiait donc sous son
propre mot de passe, que le poste ne savait plus ouvrir - et le
rafraichissement echouait avec "wrong password" sur un depot pourtant
intact.

FVaultPwd ouvre le conteneur local et s'arrete la. Le secret du depot vit
dans les reglages, doit etre le meme sur tous les appareils, et c'est lui
seul qui ouvre et rescelle l'instantane.

Les deux ne se rencontrent qu'a UN endroit, la reconciliation : le
conteneur local s'ouvre sous le sien, puis la cle est jetee avant de
toucher au depot - la garder rescellerait le depot sous le mauvais
secret. Ailleurs ils ne se croisent jamais : un coffre ne ici ne publie
pas, un coffre publie n'a plus de conteneur local.

Migration : rien a rechiffrer. Les coffres existants ont simplement les
deux valeurs egales, et cessent d'etre forces de le rester.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:58:17 +01:00
r-zakarya 17cfc11084 fix(android): le mot de passe des reglages suit le coffre a CHAQUE ouverture
La correction precedente ne portait que sur la creation et la connexion
locales, qui n'arrivent qu'une fois. Le chemin reellement emprunte est
l'ouverture depuis le cache, a chaque lancement : PushConfig y reposait
la valeur de premier lancement, l'ouverture ne la remplacait pas, et
DoSync la prefere a celle du coffre. Les reglages continuaient donc
d'afficher TEST_PWD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:44:54 +01:00
r-zakarya ea2450abfd feat(android): dire pourquoi le cache est illisible, et accepter un mot de passe comme code
"Local vault unreadable" laissait croire a une corruption, donc a une
perte, alors que le coffre est intact dans le depot. Quand Android a
lui-meme detruit la cle - verrouillage d'ecran retire ou reinitialise -
l'application le dit maintenant, et dit aussi que rien n'est perdu.

Le tri passe par un drapeau pose la ou l'invalidation se constate, pas
par un test sur le texte du message : c'est ce tri-la, fait sur des
chaines, qui detruisait autrefois le cache en se trompant.

Le code de deverrouillage accepte un mot de passe, et l'a toujours
accepte - aucune validation n'a jamais impose des chiffres. Seul
inputmode="numeric" le laissait croire, et le clavier numerique
decourageait d'essayer. Clavier complet, libelles et note mis a jour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:24:29 +01:00
r-zakarya 8b4130f13e fix(android): les reglages affichaient un mot de passe qui n'etait pas celui du coffre
PushConfig pose FPwd a la valeur de premier lancement - en debogage une
constante, en release du vide - et rien ne la remplacait ensuite. Creer
ou ouvrir un coffre local mettait a jour FSyncPwd seul, si bien que les
reglages continuaient d'afficher l'autre.

Ce n'etait pas qu'un affichage : DoSync PREFERE FPwd a FSyncPwd, au nom
de "ce qui est tape l'emporte" - or rien n'avait ete tape. Les deux
chemins de synchronisation, le rafraichissement de fond et le bouton,
travaillaient donc avec deux mots de passe differents sur le meme coffre.

Le coffre qui vient de s'ouvrir dit desormais son mot de passe, et les
reglages le montrent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:06:27 +01:00
r-zakarya a81fd88414 fix(android): la page revenait pas apres un verrouillage hors du coffre
Le masquage ne demande rien - il suffit de ne pas etre sur l'accueil pour
que la vue disparaisse avant la capture des applications recentes, coffre
ouvert ou non. La restitution, elle, exigeait un coffre ouvert.

Consulter les REGLAGES coffre ferme, puis laisser le telephone se
verrouiller, cachait donc la page pour de bon : au retour il ne restait
que le fond de la fenetre, un aplat bleu sans rien dessus et sans rien
pour en sortir.

La vue revient maintenant dans tous les cas, et APRES le verrouillage
eventuel pour ne jamais montrer une liste qu'on vient d'effacer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:58:18 +01:00
r-zakarya 900f5ba740 fix(android): poser, changer et retirer le code exigent un coffre ouvert
Deux branches de l'ouverture posaient le code a partir de ce qui trainait
dans les champs.

La premiere appliquait un "nouveau code" depose par les reglages. Elle
n'exigeait rien de celui qui le deposait : n'importe qui tenant le
telephone deverrouille pouvait en poser un, et c'est le proprietaire qui,
en ouvrant son coffre, l'appliquait contre lui-meme.

La seconde rescellait sous le code tape des que le cache ne l'etait plus,
ce qui DEFAISAIT LE RETRAIT : on retirait le code, le champ le portait
encore, et l'ouverture suivante le reposait sans qu'un mot le dise. Le
premier octet du cache le disait - VER_PIN apres un retrait annonce
reussi.

Poser et changer passent maintenant par SetUnlockCode, jumeau de
ClearUnlockCode : geste explicite, coffre deja ouvert - donc code courant
connu - et effet immediat. Le champ de l'accueil ne veut plus dire qu'une
chose, "ouvre avec ceci", et ne parait que lorsqu'un code est pose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:17:24 +01:00
r-zakarya 6c5fb2f4ea fix(android): Sync ouvre puis synchronise, et l'accueil ne demande plus le nouveau code
Le garde pose juste avant fermait la seule porte : le bouton Sync ne vit
que sur l'accueil, et une fois le coffre ouvert on est sur la liste, ou
il n'existe pas. Synchroniser devenait impossible des qu'un code etait
pose. Sync ouvre donc le coffre - ce qui verifie le code, seul LoadVault
le peut - et OpenLocalNow reprend la synchronisation ou elle s'est
arretee. Le drapeau est consomme des sa lecture, pour qu'un code faux ne
laisse pas une synchronisation en embuscade.

Le champ "new unlock code" quitte l'accueil : les reglages posent et
changent le code, il y faisait doublon sur l'ecran ou l'on veut seulement
ouvrir. Il reste dans la page, cache - c'est lui que "Set this code"
remplit avant pushConfig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:50:15 +01:00
r-zakarya 138abea88d fix(android): Sync ouvrait le coffre sans demander le code
Le garde de DoSync ne couvrait que l'empreinte, et les deux protections
s'excluent : un cache scelle par un code porte VER_PIN, donc
VaultNeedsAuth y est FAUX. Synchroniser reconstruisait le coffre depuis
le depot et l'affichait sans que le code soit demande - il ne gardait
qu'un bouton sur deux.

Exiger un champ rempli n'aurait rien valu : le depot est scelle par le
mot de passe de sync, pas par le code, donc rien ici ne le confronte a
quoi que ce soit et n'importe quelle frappe serait passee. Seul LoadVault
sait si le code est le bon, et c'est "Open vault" qui l'appelle - d'ou le
renvoi vers lui.

Place AVANT ReconcileWithRemote et PublishPending, qui sortent de DoSync
par leurs propres chemins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:41:36 +01:00
r-zakarya ba4ab18d0f feat: the last three passwords travel, and the phone can show them
The desktop keeps twenty previous versions per entry and never sent any. It
now sends three.

Three, not twenty, because the trade is not symmetrical. On the desktop they
sit encrypted column by column and never move. In the snapshot everything
travels in the clear inside the container, and comes down into a phone cache
sealed only by the device key - not by the master password. An old password
stays sensitive: it is often still valid somewhere else. The real use of the
list is short - "I changed it recently, what was it?" - and three cover it.
The other seventeen would be copies with no need facing them.

The phone reads them ON DEMAND from the raw snapshot, never into TVault:
loading them with the vault would put every entry's old passwords in the clear
in memory from the moment it opens, for something one looks at now and then.

Copying goes by RANK, not by value. The page displays the text but never sends
it back - that is the bridge's rule everywhere else, and an old password has
no reason to be the exception just because it happens to be on screen.

They leave with the plaintext at lock, on both sides.
2026-08-22 16:14:04 +01:00
r-zakarya 2010dec22a feat(android): an entry can get its icon, through the same relay as the desktop
Fetching https://<site>/favicon.ico directly would resolve a DNS name - and
open a connection - to EVERY domain kept in the vault. The list of sites where
someone holds an account is precisely what this vault protects; handing it to
the network for a sixteen-pixel drawing would be a poor trade. The relay sees
only that someone is interested in one domain, which is why the call is asked
for entry by entry and never happens on its own.

Two tries: the full host, then the second-level domain - chat.qwen.ai has no
icon at the relay, qwen.ai does. The type comes from the first bytes, never
from the extension: the relay answers PNG under a .ico address.

A dedicated patch rather than PatchEntryField, because icon_custom is a NUMBER
in the snapshot and writing it as a string would give "0" - which the desktop
reads with GetValue<Boolean> and would choke on. It is set to zero: the icon
comes from an automatic lookup, not from a choice, and that flag is what stops
the desktop from ever replacing a chosen icon during a refresh.

Base64 without line breaks. Delphi's encoder wraps at 76 characters by
default, a wrapped data: URI does not display, and that trap has already been
paid once on the containers.
2026-08-22 15:39:19 +01:00
r-zakarya 73e3faa24c feat(android): a settings screen, and the unlock code can finally be removed
Three things of different natures lived in collapsible panels on the home
screen - repository credentials, the unlock code, the log - on the one screen
where one mostly wants to open one's vault. They move to a settings sheet,
reachable from the list too.

A sheet, not a fourth view of the rail: each view costs its transform rules
and a place in back-key navigation, and settings is not somewhere one moves
about - it is a parenthesis one opens and closes.

The unlock code can now be REMOVED. It could not before: clearing the field
meant "nothing typed", not "drop the protection", so one could set a code and
never get out. It requires an open vault, and that is the proof being asked
for - the cache only let itself be read under the right code. Without that,
anyone holding the unlocked phone could lift the protection without knowing
it.

The refusal that guards every other path stays: it is what stopped a sync
from silently undoing a code. Removing is the one deliberate way out, and it
says so at the call site.

The old fields stay in the document as the source Delphi knows; the sheet is
only a view of them. Emptying them would lose the configuration at the first
sync.
2026-08-22 15:34:40 +01:00
r-zakarya 4ae845bc13 feat(android): tell when the repository has moved, without adopting it
A probe every ten minutes compares the remote fingerprint to ours and lights
an indicator. It does not pull: adopting on its own while someone is reading
an entry would change it under their eyes. The user decides when.

The body is downloaded anyway - Gitea's contents API cannot answer without it
- but it is thrown away undecrypted: no derivation, no write, just a string
comparison. The cost is network, not time or battery.

It rides the idle timer rather than a second one. That timer already beats
every 15 seconds for auto-lock; counting its beats costs an integer, where
another timer costs an object, a lifecycle, and one more chance to forget it
on shutdown.

Two indicators, two colours, two gestures. Orange: things wait HERE and the
repository does not know. Blue: the repository holds things we do not have.
One dot for both would say "something is wrong" without saying what - and one
is resolved by publishing, the other by fetching.
2026-08-22 15:28:41 +01:00
r-zakarya af2a0dfed8 fix(android): offline changes survive a refresh, and a restart
Two defects in what offline saving had just introduced, both losing data
silently - found by re-reading rather than by testing, since the device was
away.

The background refresh REPLACED the cache with the repository's version. An
edit made offline vanished at the next fetch, without a word, which is
exactly what offline saving promises to prevent. It merges now, and keeps the
flag: merging is not publishing.

And the flag lived only in memory. An application killed between the offline
save and the publish forgot it had anything to send - and the next refresh
then clobbered the cache. It is a file now, read before any refresh can run.

A file rather than a field of the sealed cache: the flag carries no secret,
its whole content is yes or no, and the cache's signature is already nine
parameters long.
2026-08-22 15:15:21 +01:00
r-zakarya a24b18e90f feat(android): saving works without a connection
The save used to start from the network: without it, nothing was kept. But
what someone has just typed is already theirs - publishing is a consequence,
not a condition. The cache is written first, always, and the repository
follows when it can.

A failed publish no longer loses anything. It raises a flag, and the toast
says both things: that it is kept, and that the repository does not know yet.
A bare "failed" would have people retype what is already there.

On conflict the two snapshots are now MERGED rather than the change replayed.
Replaying one edit onto the fresh snapshot was right while writing required
being online; with offline saving several changes can be waiting here, and
replaying one would drop the others.

Sync publishes what is pending BEFORE fetching. Fetching first would overwrite
our cache with the repository's version and the offline change would vanish
without a word - exactly what offline saving promises to prevent.

A dot on the padlock says changes are waiting: discreet until looked for,
present at every glance.
2026-08-22 14:56:04 +01:00
r-zakarya 6b3001666e feat(android): attachments can be saved, section 3 item 1 is done
Two questions, and the code was only the second one.

WHERE THE FILE LANDS. The private directory is useless - no file manager
opens it and it leaves with the uninstall - and direct external storage has
been closed since Android 10. Of the three remaining doors, MediaStore ->
Downloads is the only one needing neither a permission nor an extra screen:
since API 29 an app may write what it inserted itself. The destination picker
would need an activity result, which FMX makes painful; sharing would need a
FileProvider in the manifest. Below API 29 it would take
WRITE_EXTERNAL_STORAGE - one of the ten permissions just removed - so it
refuses and says so.

WHERE THE CONTENT IS. TVault drops it on purpose: a single attachment took
the cache from 13 to 174 Ko. It is re-read from the raw snapshot, which has
been in memory ever since saving stopped fetching before publishing. The same
data now serves two needs that had nothing to do with each other.

Decoding and writing happen off the main thread, under the waiting veil: a
few megabytes would freeze the screen, and the veil would not even paint.

What leaves the application leaves its protection: the file lands in the
clear in Downloads. The confirmation names the place rather than saying
"saved".

Release configuration compiles too - checked, since that is what section 5
caught out.
2026-08-22 00:41:05 +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 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 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 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 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 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 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 afc6119cf8 fix(android): the unlock code is asked for, and never undone in silence
Two defects, one feature made useless. The cache did seal under the code,
but nothing on the home screen asked for it: opening without one failed,
the failure was read as corruption, and the device offered to download a
vault it held perfectly well. And the next refresh rewrote the cache in
the old shape, so the code was undone seconds after being set, silently.

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

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

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

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

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

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

The write test goes with it: it proved the write path before the first
real PUT, that path now carries the vault itself, and the button, its
handler, the probe and the debug hook that revealed it were only ever
scaffolding.
2026-08-21 12:39:22 +01:00