Files
Password-Manager/android-client/PLAN.md
T
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

898 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Client Android — ce qu'il reste à faire
État au 2026-08-17. Le client lit l'instantané et l'affiche ; il n'écrit rien.
Ce document liste le reste, dans l'ordre où je le ferais.
Voir [README.md](README.md) pour l'architecture et les décisions déjà prises.
---
## 0. Ce que l'instantané contient déjà
À vérifier avant de réclamer quoi que ce soit au poste Windows : la plupart des
manques côté téléphone ne demandent **aucun** changement côté serveur.
| Champ | Dans l'instantané | Lu par le client |
|---|---|---|
| `uuid`, `title`, `site`, `username`, `password` | oui | oui |
| `folder`, `tags`, `kind` | oui | oui |
| `totp_secret` | oui | oui |
| `custom_fields` (`label`/`value`/`is_secret`) | oui | oui |
| `template` (carte, clé SSH, serveur…) | oui | oui — ligne *Type* |
| `icon_b64` | oui | oui — pastille de la liste |
| `icon_custom` | oui | non — la provenance ne change rien à l'affichage |
| `attachments` — nom, type, taille | oui | oui — nommées, pas ouvrables |
| `attachments` — `content_b64` | oui | **non** — c'est l'enregistrement qui manque |
| `updated_at` | oui | oui |
| `created_at` | oui | **non** |
| `folders` (liste, couleur, icône) | oui | oui — le nom seulement |
| `avatar_b64`, `username` (compte) | oui | **non** |
| `tombstones` | oui | **non** — n'aura de sens qu'avec l'écriture |
| `favorite`, `pinned` | **non** — device-local par conception | — |
| corbeille | **non** — voyage dans les sauvegardes, pas la sync | — |
Ce qui manque encore ne demande donc **rien** au poste Windows : c'est de la
lecture côté téléphone. Seules les favoris/épinglés et la corbeille
demanderaient une décision côté Windows — et pour les deux, la décision
actuelle est délibérée.
---
## 1. À vérifier sur téléphone — aucun code
L'émulateur n'a ni empreinte ni écran de verrouillage, donc `PMA.Store` est
retombé sur la clé sans contrainte à chaque essai. Ces trois points n'ont
jamais été exercés :
1. **Création de la clé sous authentification** — `SaveVault(…, True)` doit
écrire un en-tête à `2`, et le journal doit dire « protégé par
biométrie/code » et non « AUCUNE protection ».
2. **Ouverture** — `VaultNeedsAuth` → invite système → déchiffrement dans les
30 s de validité de la clé. Au-delà, `LoadVault` échoue **sans** effacer le
cache : la garde sur `UserNotAuthenticated` est écrite mais jamais éprouvée.
3. **Cache d'un lancement à l'autre** — fermer, rouvrir, *Ouvrir le coffre* :
ni réseau ni mot de passe, et 0 ms de dérivation.
Un quatrième cas mérite d'être provoqué exprès : **enrôler une nouvelle
empreinte** invalide la clé définitivement
(`KeyPermanentlyInvalidatedException`). Le cache doit alors être effacé et
retéléchargé, pas rester illisible. C'est l'autre branche de la même garde.
---
## 1 bis. ~~La décision qui bloque le cache~~ — RÉSOLU le 2026-08-15
Mesuré sur appareil (schéma d'écran, aucune empreinte enrôlée) :
```
cache ecrit - protege par biometrie/code (aucune invite à l'écriture)
--- redémarrage à froid, saisie du schéma ---
4 entrees, 2 en corbeille (aucune ligne « dechiffre en … ms »)
HTTP 304 en 136 ms
304 : inchange, coffre deja ouvert
```
Les points §1.1, §1.2, §1.3 et §1.5 sont donc validés d'un coup : clé créée sous
authentification, ouverture par schéma, cache survivant à un démarrage à froid
sans dérivation, et rafraîchissement de fond silencieux.
**Trois pièges se cachaient derrière, aucun visible à la compilation** :
1. `USE_BIOMETRIC` absente de la liste par défaut de Delphi — et
`CanAuthenticate` lève au lieu de répondre `False`.
2. FMX n'affiche pas l'invite depuis notre activité : il lance
`BiometricFragmentActivity`, qui doit être déclarée au manifeste.
3. **Le digest du MGF1.** `setDigests()` n'autorise que celui du message ;
celui du MGF1 est une autorisation distincte dont le défaut est SHA-1.
Le chiffrement passait quand même — la clé publique reconstruite est servie
par le fournisseur logiciel, qui accepte n'importe quel MGF1 — et produisait
un chiffré valide que le matériel refusait de lire. Le symptôme
n'apparaissait qu'au lancement suivant.
**§1.4 validé aussi** : une minute d'attente, téléphone verrouillé puis
déverrouillé, réouverture → la fenêtre de 30 s avait expiré, l'invite est
réapparue, et le cache était intact. C'est précisément le cas qui détruisait le
fichier avant que `LoadVault` cesse de trancher lui-même.
**§1.6 validé** (2026-08-15), par la variante la plus radicale : suppression du
verrouillage d'écran, qui invalide définitivement toute clé Keystore exigeant
une authentification. La clé privée RSA disparue, `vault.bin` est illisible
pour toujours, et l'application a bien reparti sur un retéléchargement.
Ce qui compte est *quand* elle a effacé : l'ouverture automatique au lancement
a constaté l'indisponibilité et **gardé le cache** ; il a fallu un appui
explicite sur *Open vault* pour qu'elle le supprime. Diagnostic sûr, puis geste
de l'utilisateur — la règle posée après l'incident du matin tient.
**§1 est donc complet, six points sur six.**
<details>
<summary>Le diagnostic d'origine, conservé pour la trace</summary>
## La décision qui bloquait le cache
Le cache ne s'écrit presque jamais. Journal sur appareil :
```
dechiffre en 4446 ms
cache non ecrit : EJNIException - android.security.key…
```
**Cause.** Une clé Keystore créée avec `setUserAuthenticationRequired` +
`setUserAuthenticationValidityDurationSeconds` exige une authentification
récente **pour chiffrer autant que pour déchiffrer**. Or l'écriture suit un
téléchargement silencieux, sans invite : passé la fenêtre de 30 s depuis le
dernier déverrouillage, `Cipher.init(ENCRYPT_MODE)` lève. La toute première
écriture avait réussi seulement parce que le téléphone venait d'être
déverrouillé — d'où un test qui passe puis ne passe plus.
Conséquence : l'application fonctionne, mais redérive 4,4 s à chaque
lancement. Le cache, qui est toute la raison d'être de `PMA.Store`, ne sert
jamais.
**Trois issues, par ordre de justesse :**
1. **Paire de clés asymétrique** — c'est le motif documenté par Android pour
« écrire à tout moment, lire sous authentification ». La partie publique
chiffre sans invite, la partie privée (protégée) déchiffre. Comme RSA ne
chiffre pas 16 Ko, il faut un chiffrement hybride : une clé AES aléatoire
par écriture, enveloppée par la clé publique. Plus de fenêtre de validité,
plus de course contre le temps. ~80 lignes de JNI.
2. **Authentifier avant d'écrire** — une invite à chaque synchronisation de
fond, ce qui détruit précisément le silence recherché.
3. **Allonger la fenêtre** — atténue sans corriger, et affaiblit la garantie
proportionnellement.
Retenir la 1. Les deux autres échangent une propriété de sécurité contre du
confort, ce qui est le mauvais sens pour un gestionnaire de mots de passe.
</details>
## 1 ter. ~~Écritures complexes — Skia~~ — DEVENU INUTILE le 2026-08-17
Un nom de pièce jointe en arabe s'affichait en désordre : le moteur de texte
d'origine de FMX ne met pas en forme les écritures complexes — pas de
réordonnancement bidirectionnel, pas de liaison des lettres. `GlobalUseSkia`
réglait cela, au prix de `libsk4d.so` (25 Mo) et d'un **APK passé de 13 à 22 Mo**.
**Retiré**, avec le style vendu, quand l'interface est passée au moteur du
système (§3 bis) : Skia ne servait qu'aux libellés FMX, et il n'en reste aucun.
Le moteur fait sa propre mise en forme — vérifié sur l'appareil, le même nom
arabe se lit correctement. L'APK **retombe à 12,7 Mo**.
Ce qui reste vrai de cet épisode, et qui gouverne encore la fabrication :
`Deploy` reconstruit `library\lib` avant `aapt`, donc tout ce qu'on y pose à la
main disparaît — aujourd'hui nos icônes, hier Skia. D'où
[repack-apk.ps1](repack-apk.ps1), qui refait *aapt → zipalign → signature* sur
le dossier déjà prêt. Une entrée `DeployFile` en bonne et due forme a été
tentée d'abord : MSBuild l'ignore, comme il ignore toutes les autres ici.
[build-apk.ps1](build-apk.ps1) enchaîne les quatre étapes, y compris la
régénération de `web.res` — l'oublier embarque la page d'avant l'édition, et le
symptôme se lit alors comme un bug applicatif.
## 1 quater. ~~La comparaison de sha ne se declenche pas~~ — RESOLU le 2026-08-17
Mesure sur appareil, deux ouvertures successives du cache :
```
Vault opened from cache in 139 ms
displayed in 14 ms <- l''ouverture ne coute rien
known sha: 03960ff908fb0d09a900ab69f8738fd581aef98f
remote sha: 03960ff908fb0d09a900ab69f8738fd581aef98f
decrypted in 5492 ms <- alors qu''elles sont identiques
```
Les deux empreintes s''affichent **egales** et `PMA.SyncClient` les compare
**differentes** : la branche `ssNotModified` n''est pas prise, et l''avertissement
place juste apres, qui ne s''affiche que si `LRes.ETag = FETag`, ne sort pas non
plus. Seul un caractere invisible explique cela, et une relecture du chemin
complet ne l''a pas trouve.
**Ce qui est ecarte** : le transport Gitea est bien choisi (le journal ecrit
« known sha » et non « If-None-Match », ligne conditionnee par `IsGitea`) ;
`SaveVault` enregistre l''empreinte qu''on lui passe ; `vault.bin` est bien
reecrit (191 146 octets, horodatage a jour) ; et le dechiffrement reussit, donc
le conteneur extrait est le bon.
**Contourne, pas corrige** : la comparaison passe par `SameText` sur des valeurs
elaguees - correct en soi sur un hexadecimal, ou deux empreintes distinctes ne
peuvent pas differer par la seule casse ou un blanc - et le journal affiche
desormais les deux **longueurs**. Le prochain rafraichissement reel dira ce
qu''etait le caractere de trop.
**Rendu peu visible** par `REFRESH_MIN_MINUTES` : l''ouverture n''appelle plus le
serveur qu''au plus une fois par quart d''heure. Le cout ne se paie donc plus a
chaque ouverture, mais il se paiera encore une fois par quart d''heure tant que
la comparaison reste inerte.
---
## 2. Durcissement — petit, à faire avant tout usage réel
Par ordre de gravité, pas de taille.
1. ~~**`android:allowBackup="false"`**~~ — **fait**. Le répertoire privé
contient `vault.bin` ; la sauvegarde Google n'a rien à y faire, même si ce
qui en sortirait serait chiffré par une clé restée dans la puce.
2. ~~**Retirer `usesCleartextTraffic="true"`**~~ — **fait le 2026-08-17**, mais
pas en le retirant : remplacé par un
[network_security_config.xml](android-res/xml/network_security_config.xml) qui
n''autorise le clair que vers `127.0.0.1` et `localhost`, **nommément**, et
l''interdit partout ailleurs. L''attribut ne faisait pas cette distinction :
une URL mal saisie envoyait le jeton en clair sur le réseau sans que rien ne
le signale. Maintenant elle échoue tout de suite, ce qui est le comportement
voulu — bruyant, pas silencieux. L''essai local par `adb reverse` continue de
passer.
3. ~~**`FLAG_SECURE`**~~ — **fait**, [PMA.ScreenGuard.pas](PMA.ScreenGuard.pas),
**en Release seulement** : en debug le drapeau interdit aussi les captures
d'écran de développement. Le vérifier demande donc une compilation Release.
Complémentaire du masquage sur `WillBecomeInactive`, pas redondant : celui-ci
traite l'aperçu des récentes, le drapeau traite en plus les captures
manuelles, l'enregistrement d'écran et la duplication vers un écran externe.
4. ~~**Marquer le presse-papiers sensible**~~ — **fait et vérifié** : Android
affiche `•••••` au lieu du contenu. Passe par `ClipboardManager` en direct,
le drapeau étant un extra du `ClipDescription` qu'`IFMXClipboardService`
n'expose pas. Voir [PMA.Clipboard.pas](PMA.Clipboard.pas). Ancien texte :
depuis Android 13 le système
affiche un aperçu de ce qui vient d'être copié, mot de passe compris.
`ClipDescription` + extra `android.content.extra.IS_SENSITIVE`.
5. **Sortir l'URL et le jeton du code source.** Ce sont encore des constantes.
Le mot de passe de sync, lui, est déjà rangé dans le fichier chiffré par le
Keystore ; reste à en faire autant pour les deux autres, et à supprimer le
préremplissage depuis [PMA.LocalConfig.inc](PMA.LocalConfig.template.inc).
6. ~~**Verrouillage sur inactivité au premier plan**~~ — **fait**, 3 minutes,
échantillonné toutes les 15 s. Reste à constater passivement sur appareil.
7. **Synchroniser passait à côté de la porte** — corrigé le 2026-08-15 : il
reconstruisait le coffre depuis le serveur et l'affichait sans jamais
demander l'authentification, soit le même résultat qu'*Ouvrir le coffre*, à
un bouton près. Signalé par l'utilisateur, pas par un test — une porte
verrouillée ne se remarque que si on essaie l'autre.
---
## 3. Compléter la lecture — moyen
Rangé par ce que ça apporte réellement sur un téléphone.
1. **Pièces jointes** — *listées*, pas encore enregistrables.
Une seule pièce jointe a fait passer le cache de **13 à 174 Ko** : le
contenu voyage en base64 dans l'instantané, et il est déchiffré en mémoire
avec le reste. `TVault` lit donc nom, type et taille, et **jette
`content_b64`** — le garder pour tout le coffre alors que rien ne l'affiche
serait payer sans rien acheter.
Le détail affiche l'existence du document. Ce qui manque est
l'enregistrement, et ce qui le bloque n'est pas le code mais **où un fichier
atterrit sur Android 10+** : le répertoire privé ne sert à rien à
l'utilisateur, *Téléchargements* passe par `MediaStore`, le partage par un
`Intent`. Le jour venu, le contenu se relira depuis le JSON brut.
À surveiller : quelques PDF suffiraient à peser plusieurs Mo, retéléchargés
entièrement à chaque instantané nouveau.
2. ~~**Filtre dossier**~~ — **fait**, une rangée de pastilles avec leur compte,
alimentée par le tableau `folders`. Le filtrage se fait dans la page, donc
sans aller-retour, et le choix survit à un rafraîchissement de fond. **Pas de filtre d'étiquette** : la recherche texte couvre déjà les
étiquettes, en taper le nom suffit.
3. ~~**Icônes**~~ (`icon_b64`) — **fait**. Elles descendent telles quelles dans
la page, en URI `data:` : le moteur décode, y compris le SVG, que FMX ne
savait pas charger. Sans icône, l'entrée retombe sur son initiale.
4. ~~**`template`**~~ — **fait**, affiché comme *Type* dans le détail. Adapter
en plus les intitulés d'une carte bancaire reste à voir.
5. ~~**Tri par nom**~~ — **fait** dans `TVault.Search`. L'ordre de l'instantané
est celui de la base, donc arbitraire à l'œil.
6. **Liste virtuelle** le jour où la reconstruction à chaque frappe se voit. Le
`innerHTML` complet est refait à chaque caractère ; sur quelques centaines
d'entrées c'est imperceptible. Marqué `ponytail:` dans la page, pas avant.
---
## 3 bis. ~~Faudrait-il des composants Delphi du tout ?~~ — TRANCHÉ le 2026-08-17
**Non. Il n'en reste aucun.** Les trois écrans sont une page HTML rendue par le
moteur du système ([web/app.html](web/app.html), servie par
[PMA.WebUI.pas](PMA.WebUI.pas)). Une sonde a décidé plutôt qu'un débat : la même
liste peinte par le moteur, en quarante lignes de CSS, rendait mieux que ce qui
avait coûté la journée à peindre à la main — et mettait l'arabe en forme seule.
Ce que la migration a réglé, et qui revenait à chaque écran : un gabarit absent
du style chargé, un ordre d'alignement laissé au hasard, un `HitTest` qui avale
un appui, une vue native qui se peint par-dessus tout. Le dernier à partir a été
l'écran de déverrouillage, pour une raison précise : dans un `TEdit` Android le
curseur ne se place pas où on le touche, donc un jeton long est incorrigible.
**Le pont n'ouvre aucun port.** `evaluateJavascript` descend les données,
une navigation `cmd://` interceptée remonte les actions. Deux pièges y sont
enterrés, à ne pas re-découvrir :
- FMX répond **toujours** au moteur qu'il n'a pas pris la navigation en charge.
Sur un schéma inconnu, le moteur essaie donc de la charger et remplace la page
par son écran d'erreur. D'où l'**iframe jetable**, où l'échec reste confiné.
- Le chargement de la page ne transporte pas l'encodage
(`loadDataWithBaseURL` reçoit `nil`), et `Navigate` refuse une URL `data:`.
**`app.html` doit donc rester en ASCII pur** ; les données, elles, passent par
une chaîne JNI et n'ont pas ce problème.
**Les secrets ne traversent pas le pont.** La liste reçoit noms, sites et
identifiants — ce qui est déjà à l'écran. Copier depuis une ligne envoie un
**rang**, et Delphi relit le coffre chez lui. Seule l'entrée ouverte a ses
valeurs dans le tas JS, effacées par `PM.wipe` au verrouillage.
Reste en Delphi, et y restera : Keystore, biométrie, presse-papiers sensible,
`FLAG_SECURE`, réseau, déchiffrement.
**Ce que `js/` n'a pas donné.** Le CSS du poste est réutilisable presque tel
quel et c'est lui qui porte l'apparence ; `app.js` non — 12 000 lignes qui
supposent la session, le CSRF et l'API complète. La page du téléphone est
autonome.
**Le thème n'est pas encore exactement celui de la recherche globale.** Le
dégradé, les blocs translucides et les arrondis sont repris ; espacements,
élévation et densité restent au jugé. C'est maintenant du CSS, donc peu coûteux
à reprendre capture en main.
## 4. Le chemin d'écriture — le gros morceau
C'est ce qui ferait du téléphone un second appareil plutôt qu'une consultation.
À ne pas commencer avant que le reste soit stable : une erreur ici perd des
données, alors qu'une erreur en lecture n'affiche qu'une ligne de travers.
### La contrainte qui gouverne tout le reste
**Le client doit republier ce qu'il n'a pas compris, à l'identique.**
`TVault` ne lit qu'un sous-ensemble des champs. S'il resérialise le coffre à
partir de ce qu'il a lu, un envoi depuis le téléphone efface silencieusement
`icon_b64`, `template`, `attachments`, `created_at`, l'avatar, les
tombstones — et le poste Windows adopterait cette version au prochain tirage,
puisque la fusion est un dernier-écrit-gagne sur `updated_at`.
Donc : **garder le `TJSONObject` d'origine** et ne modifier en place que ce que
l'utilisateur a changé. C'est une contrainte de conception, pas une
optimisation — elle décide de la forme de `TVault` avant la première ligne
d'interface d'édition.
### Dans l'ordre
1. ~~**`updated_at` dans `TVaultEntry`**~~ — **fait**, et déjà affiché comme
*Modifié le* dans le détail. Rien n'était arbitrable sans lui.
2. ~~**AES-GCM en chiffrement**~~ — **fait le 2026-08-17**, et vérifié par une
implémentation tierce. `EncryptContainer` produit le conteneur exact du
poste ; sel de 32 octets et IV de 12 tirés de `SecureRandom` (Android) ou
`BCryptGenRandom` (Windows), jamais `Random`.
**La vérification compte autant que le code.** Un aller-retour avec
soi-même ne prouve que la cohérence interne : il passait alors que le
conteneur portait deux défauts. C'est Node — mêmes primitives que WebCrypto
— qui les a montrés en relisant un conteneur produit par le téléphone :
- le base64 de Delphi est **coupé à 76 caractères**, celui de WebCrypto ne
l'est jamais. Les décodeurs tolérants l'acceptent, mais dépendre de leur
tolérance n'est pas une garantie ; `B64NoWrap` produit désormais la même
forme.
- le littéral accentué du contrôle sortait **déjà abîmé du compilateur** —
ce fichier n'a pas de marque d'ordre d'octets, et Delphi le lit en ANSI.
Le défaut était dans le test, pas dans le chiffrement, mais seul le
contrôle croisé pouvait le distinguer.
Le contrôle tourne au lancement en `DEBUG` et dépose `selfcheck.json` dans
le répertoire privé, pour être relu depuis le PC :
`adb shell run-as … cat …/files/selfcheck.json`.
3. **`PUT` dans [sync.php](../server-php/sync.php)**, qui répond aujourd'hui
405. Même authentification, `If-Match` obligatoire, **412** si l'ETag ne
correspond plus, écriture atomique (fichier temporaire puis renommage —
sans quoi une coupure laisse un conteneur tronqué, donc un coffre perdu),
et un plafond de taille.
4. **La fusion**, calquée sur `applyRemoteSnapshot` côté JS : dernier-écrit-
gagne par `uuid` sur `updated_at`, tombstones qui propagent les
suppressions, et l'arbitrage de résurrection (une entrée modifiée après sa
date de suppression survit à la tombstone).
5. ~~**L'interface d'édition**~~ — **faite**, éditeur en ligne sur la ligne du
mot de passe. `updated_at` est bumpé en UTC par `PMA.VaultPatch`.
6. ~~**Le conflit 412**~~ — **écrit**, sous forme de **409** (le code de Gitea),
trois essais, en rejouant tirer→patcher→publier. **Jamais éprouvé** : il
faudrait publier depuis le poste pendant que le téléphone enregistre.
### Vérifié sur appareil — 2026-08-17
```
save: attempt 1 - GET
save: decrypt+patch in 4519 ms
save: re-encrypt in 23 ms
save: published, new sha 802515d4...
```
Les 23 ms mesurent l'économie de la clé réutilisée : la seconde dérivation
valait les mêmes 4,5 secondes que la première.
**Ce qui reste ouvert**, par ordre d'importance :
- le **409** n'a jamais tourné ;
- **pas d'édition hors ligne** : le téléphone patche l'instantané qu'il vient
de tirer, donc sans réseau *Save* échoue. C'est le prix de ne pas avoir de
fusion à écrire. Une file de modifications en attente la ramènerait, avec
l'arbitrage qui va avec ;
- **un seul champ** est modifiable. `PatchEntryField` prend déjà le nom du
champ, donc l'identifiant ou le site coûteraient une ligne ; créer ou
supprimer une entrée, en revanche, exige uuid et tombstones.
### L'interface, remise d'aplomb — 2026-08-21
Quatre défauts trouvés en s'en servant, tous nés du même endroit : le rail
pose les vues au repos hors du cadre, à `translateX(100%)`.
- **Le panneau glissait de côté.** `overflow:hidden` n'interdit que le geste ;
le moteur garde le droit de déplacer une boîte pour amener à l'écran le champ
qui vient de prendre le focus, et les vues hors cadre lui en laissent la
place. Toute boîte qui glisse revient maintenant à zéro, attrapée en phase de
capture — sauf la bande de dossiers, seule à défiler latéralement de plein
droit. Premier essai raté : j'avais clampé les panneaux défilants, la CSS
était bien dans la bibliothèque livrée et le comportement n'avait pas bougé.
- **Le clavier changeait de forme et ne se refermait plus.** La touche `→|`
demande « champ suivant », et le suivant dans le document est sur une vue au
repos. Aucun champ d'une vue au repos ne garde le focus désormais, et la
validation replie le clavier au lieu de sauter ; dans l'éditeur, elle
enregistre.
- **Le bouton « replier le clavier » ne faisait rien**, et c'était chez nous :
la vue web n'étant pas un champ que le système connaît, il n'intercepte pas
la touche retour pour nous, et `OnHardwareBack` la consommait toujours — le
coffre se verrouillait pendant que le clavier restait déplié.
`hideSoftInputFromWindow` dit s'il y avait quelque chose à replier : c'est le
signal synchrone qui permet de ne consommer que dans ce cas.
- **La rangée d'édition dépassait le cadre** : le champ gardait `width:100%` et
trois boutons s'ajoutaient à côté.
Mesuré sur l'appareil par `dumpsys input_method` avant et après, pas déduit :
`mInputShown` restait à `true`, il passe à `false` et l'application reste au
premier plan.
**L'essai du chemin d'écriture est retiré** — bouton, procédure, branche du
pont et le crochet de débogage qui le révélait. Il prouvait ce chemin avant le
premier vrai PUT ; ce chemin porte le coffre maintenant.
### Ce qu'on peut s'épargner
La création et la suppression d'entrées peuvent attendre : modifier un mot de
passe depuis le téléphone couvre l'essentiel du besoin, et n'exige ni
tombstones ni génération d'uuid.
---
## 5. Empaquetage
- Icône, nom d'application, numéro de version.
- Signature avec un magasin de clés dédié, pas la clé de débogage.
- Revue des permissions : `INTERNET` suffit.
- Retirer le journal de diagnostic de l'écran de déverrouillage, ou le mettre
derrière un appui long.
- Le mot de passe préchargé depuis `PMA.LocalConfig.inc` doit disparaître.
---
## 6. Remplacer l'endpoint par un dépôt Gitea
Piste sérieuse pour le §2 item 2 : au lieu d'héberger `sync.php` en HTTPS avec
un certificat, déposer l'instantané chiffré dans un dépôt **privé** du Gitea
déjà en service. Plus rien à héberger, plus de certificat à renouveler, et
l'accès depuis l'extérieur vient gratuitement.
**La mécanique tombe terme à terme sur ce qui est déjà écrit.** L'API rend le
fichier avec son `sha` et exige ce même `sha` pour écrire ; un `sha` périmé
donne un **409**. C'est exactement le rôle de l'`ETag` et du **412** : la
boucle relire-refusionner-renvoyer du poste Windows, bornée à trois essais,
fonctionne sans changer de forme.
### Lire
```
GET /api/v1/repos/{owner}/{repo}/contents/vault-sync.json?ref=main
Authorization: token <PAT>
```
Réponse : `{ "content": "<base64>", "encoding": "base64", "sha": "<blob sha>" }`.
Le `content` est le conteneur `pm-encrypted-export-v1`, déjà chiffré — Gitea ne
voit que des octets. Le `sha` prend la place de l'ETag, et se range avec le
coffre comme lui, puisqu'il décrit le clair détenu.
**404** = pas encore d'instantané, à traiter comme le 404 actuel : premier
envoi, pas une erreur.
### Écrire
```
PUT /api/v1/repos/{owner}/{repo}/contents/vault-sync.json
Authorization: token <PAT>
{ "message": "sync", "content": "<base64>", "sha": "<sha lu>",
"branch": "main" }
```
Sans `sha`, Gitea refuse d'écraser un fichier existant ; avec un `sha` périmé
il répond **409**. C'est le `If-Match` qu'on avait dû implémenter à la main.
### Le jeton
**Gitea ne sait pas limiter un jeton à un dépôt** — vérifié sur 1.26.1. Ses
portées sont par *catégorie* et valent pour tout le compte : `read:repository`
donne la lecture de **tous** les dépôts que le compte peut lire. Rien à voir avec
les jetons fins de GitHub. (Ce paragraphe disait le contraire ; c'était faux.)
Ce qu'on peut faire, par ordre de coût :
- **`read:repository` seul**, tant que le téléphone ne fait que lire. C'est le
choix actuel : un jeton qui ne peut rien écrire coûte peu s'il fuite.
- L'écriture ne se justifiera qu'au §4, et pour publier depuis le poste c'est
**un autre jeton, sur une autre machine**.
- Vraie isolation, si elle devient nécessaire : un **compte Gitea dédié**,
collaborateur du seul dépôt de sync.
Il remplace le jeton Bearer actuel et se range au même endroit — `settings.bin`,
scellé par la clé AES du Keystore, lisible sans authentification. Le
raisonnement ne change pas : il n'ouvre que du chiffré.
### Ce que ça coûte, et pourquoi pas GitHub
**L'historique de Git ne s'efface pas.** Chaque synchronisation est un commit,
et changer de mot de passe de sync n'enlève rien : toutes les versions
antérieures restent, chacune chiffrée sous le mot de passe de son époque. Un
ancien mot de passe faible resterait attaquable indéfiniment. Sur un serveur
qui écrase un fichier, ce problème n'existe pas.
C'est vivable sur **son propre** Gitea — le disque est à soi, l'historique se
purge. Ça ne l'est pas sur GitHub, où le chiffré part chez un tiers, répliqué
et sauvegardé, avec un dépôt rendu public par erreur pour scénario du pire :
attaquable hors ligne, par n'importe qui, pour toujours.
### État : la lecture fonctionne contre un vrai dépôt — 2026-08-17
[PMA.SyncClient.pas](PMA.SyncClient.pas) parle les deux transports, **reconnus à
l'URL** : `/api/v1/repos/` → Gitea, sinon l'endpoint PHP. Le PHP est gardé le
temps que le poste Windows publie aussi dans le dépôt ; sans lui la migration
exigerait de tout basculer d'un coup, des deux côtés, sans retour possible.
Ce qui change côté Gitea, et rien d'autre : l'en-tête est `token <PAT>` et non
`Bearer` (l'un à la place de l'autre donne un 401 muet), le corps est du JSON
dont il faut extraire `content` en base64, et **la comparaison d'empreinte se
fait dans le client** — l'API ne connaît pas `If-None-Match`, donc le corps est
retéléchargé, mais la dérivation de clé, elle, est bien épargnée. Le champ
`ETag` du résultat porte le `sha` : l'appelant n'a pas à savoir lequel des deux
transports lui répond.
**Vérifié sur téléphone** contre `Zaki/pm_sync` (Gitea 1.26.1), conteneur déposé
par l''interface web :
```
GET .../api/v1/repos/Zaki/pm_sync/contents/vault-sync.json
HTTP 200 in 1079 ms
304: unchanged, vault served from cache
4 entries, 2 in trash
```
Le premier appel télécharge, déchiffre et écrit le cache avec le `sha` ; le
second retrouve le même `sha` et épargne la dérivation. Le conteneur d''export
porte la corbeille, d''où les 2 en corbeille — un instantané de sync n''en aurait
aucune.
Deux frottements rencontrés en chemin, tous deux corrigés :
- les coordonnées n''étaient enregistrées qu''après un **déchiffrement** réussi,
donc un mot de passe faux faisait perdre l''URL et le jeton à chaque essai. Un
`200` prouve l''adresse et le jeton ; c''est là qu''elles sont retenues.
- le rafraîchissement au lancement est **silencieux** et utilise le mot de passe
rangé avec le coffre, pas le champ. Changer de conteneur demande donc un
*Sync* explicite au moins une fois.
### Le poste publie aussi — vérifié le 2026-08-17
Les deux bouts parlent Gitea, et le trajet complet a tourné : le poste pousse
(`sync from test`, 2ᵉ commit dans `pm_sync`), le téléphone lit l''instantané
publié. La preuve tenait dans une entrée : *gitea token read*, créée sur le
poste, est apparue sur le téléphone sans jamais passer par `sync.php` ni WebDAV.
Côté poste, tout est confiné à deux endroits : le pont ([UMainForm.pas](../delphi-backend/UMainForm.pas))
change l''en-tête et le type de contenu, et [app.sync.js](../js/app.sync.js) lit
et construit l''enveloppe JSON. Le `sha` remplace l''ETag partout, et le **409**
de Gitea est traité comme le **412** de WebDAV — on rejoue pull→fusion→push.
Trois détails qui coûtent une soirée si on les redécouvre :
- `sha` **omis** et non vide au premier envoi : Gitea lit sa présence comme
« remplace cette version précise ».
- `branch` omis aussi, pour prendre la branche par défaut du dépôt plutôt qu''un
nom devine.
- le jeton du poste doit être en **`write:repository`** ; celui du téléphone
reste en lecture.
**Piège rencontré, sans rapport avec la sync** : `applySettingsTab` associe les
sections de réglages aux onglets **par le texte de leur intitulé**. Renommer
« Sync (WebDAV) » a fait retomber la section sur `general` — pas perdue, mais
sous le mauvais onglet, ce qui est pire qu''absente.
**Reste à faire** : le PUT depuis le téléphone (§4 seulement — la lecture
n''écrit rien), retirer WebDAV le jour où il n''a plus d''usage, et une purge
périodique de l''historique — sans quoi le dépôt grossit
d'un instantané complet à chaque synchronisation, pièces jointes comprises.
## 7. Se servir de l'application seule — compte local
Aujourd'hui le téléphone ne sait que **lire un coffre venu d'ailleurs** : sans
URL, sans jeton et sans mot de passe de synchronisation, il n'affiche rien.
On veut qu'il serve seul, comme le poste : on s'inscrit, le coffre vit là, et
la synchronisation devient un ajout facultatif.
### Ce que ça entraîne, et qu'il vaut mieux dire tout de suite
Un coffre vide qu'on ne peut pas remplir ne sert à rien. **S'inscrire entraîne
donc créer des entrées depuis le téléphone** — précisément ce que §4 avait
écarté. C'est le vrai morceau ; l'écran de connexion n'en est que la façade.
### Le coffre local devient l'autorité, chiffré sous le mot de passe
Aujourd'hui `vault.bin` porte l'instantané **en clair**, scellé par le Keystore
(et, depuis peu, par un code). Le conteneur chiffré sous mot de passe, lui,
n'existe qu'au bout du réseau. Un compte local inverse la charge :
- un fichier local porte **exactement ce que porterait le dépôt** —
`EncryptContainer(instantané, mot de passe, 600k)` ;
- `vault.bin` reste ce qu'il est : le cache d'ouverture rapide.
Deux raisons, pas une : c'est le modèle du poste — le mot de passe maître
protège le coffre au repos, pas seulement le matériel — et **le format reste
celui du dépôt**, donc la synchronisation continue de marcher sans convertir
quoi que ce soit.
### Le nom d'utilisateur
Le conteneur le porte déjà (`username`, écrit par `buildSyncSnapshot`). Un seul
compte par appareil : plusieurs coffres sur un même téléphone est une envie
supposée, pas un besoin constaté. Le nom saisi se compare à celui du conteneur
après déchiffrement — c'est le mot de passe qui ouvre, le nom qui identifie.
### S'inscrire
Forge un instantané vide, le chiffre sous le mot de passe choisi, l'écrit
localement. Aucun réseau. **Le poste ne connaîtra pas ce compte** : il crée les
siens dans SQLite et rien ne les rapproche aujourd'hui. Un coffre né sur le
téléphone qu'on voudra ensuite ouvrir sur le poste demandera un import.
### Par étapes
1. **Identité** : écran connexion / inscription, conteneur local, coffre vide.
2. **Créer une entrée** — l'étape qui rend l'application utile seule. Un uuid
neuf, `updated_at` posé ; pas de tombstone, on ne supprime pas encore.
3. **Modifier n'importe quel champ** — fait le 2026-08-21. `Name`, `Site`,
`Username`, `Mot de passe` (le corps pour une note) sont éditables. Le nom du
champ voyage dans la ligne du détail (`TDetailRow.Field`) plutôt que d'être
déduit du libellé côté page : un intitulé est de la prose, et le jour où il
change, la page enverrait un nom de champ inexistant. Delphi ne le croit pas
sur parole pour autant — quatre noms admis, le reste refusé, parce que
l'instantané porte aussi des tombstones et des pièces jointes que
`PatchEntryField` écraserait sans rien y comprendre.
Une ligne vide s'affiche désormais **si elle est éditable** : un identifiant
absent qu'on ne pourrait pas ajouter serait une impasse. Vider est permis,
sauf sur un mot de passe où ce serait presque toujours une fausse manoeuvre.
**Supprimer** — fait le 2026-08-21. `DeleteEntry` retire la ligne **et** pose
la pierre tombale `{uuid, deleted_at}` : les deux, sinon rien. Retirer la
ligne seule ne supprime pas — le poste tient toujours la sienne, et le
prochain tirage la ramènerait comme une entrée qu'on n'a jamais eue. Et
l'heure compte : l'arbitrage du poste garde une entrée modifiée *après* la
suppression plutôt que de la tuer une seconde fois.
**Sans retour depuis le téléphone**, et c'est délibéré : la corbeille du
poste est un drapeau `deleted` qui ne voyage pas dans la synchronisation, donc
une suppression qui s'y arrêterait ne se propagerait nulle part. D'où le
bouton en bas d'écran, en deux temps, qui se désarme seul au bout de quatre
secondes — un bouton armé indéfiniment se fait toucher par mégarde bien plus
tard.
4. **Réconcilier avec la synchronisation** : un coffre né seul auquel on
ajoute un dépôt. C'est là que la fusion revient, et elle a été évitée
jusqu'ici pour de bonnes raisons.
### Vérifié : le poste reprend ce que le téléphone crée — 2026-08-21
Deux entrées créées sur le téléphone, puis synchronisées depuis le poste :
importées sans rien à corriger. C'est ce qui valide `AddEntry` — la forme de
l'entrée, l'uuid, les horodatages — autrement que par relecture du code.
### Le coût de la dérivation, et où il est parti
Ajouter une entrée prenait cinq à sept secondes. Le journal a tranché :
`key derived in 4395 ms`. Chaque enregistrement redérivait le mot de passe
maître pour retrouver la clé qui venait de servir.
Deux temps :
1. **La clé vit le temps de la session.** Réutilisée seulement si le conteneur
porte le même sel et le même nombre d'itérations — le poste peut avoir
rechiffré le sien avec un sel neuf, et une clé qui ne convient pas échoue
sur la marque de GCM en accusant un mot de passe qui serait bon.
2. **Elle est rangée dans le cache**, à côté du coffre en clair et du mot de
passe qui ouvre le conteneur. C'est ce voisinage qui rend l'ajout
acceptable : elle n'expose rien que ce fichier ne détienne déjà. Sans elle,
le *premier* enregistrement de chaque session repayait les 4,5 s, puisque
ouvrir depuis le cache ne dérive rien.
Un cache écrit par une version antérieure ne porte pas de clé : `LoadVault`
rend `nil`, on redérive une fois, et l'écriture suivante la range.
Reste dérivant une fois : un coffre **fraîchement inscrit** — `EncryptContainer`
ne rend pas la clé qu'il vient de dériver.
**Mesuré sur l'appareil**, au deuxième ajout d'entrée :
```
save: attempt 1 - GET
opened with the key already in hand, in 48 ms
save: decrypt+patch in 64 ms
save: re-encrypt in 23 ms
save: published, new sha e14eabcf...
```
48 ms là où le journal disait 4395. Ce qui reste dans l'attente est le GET et
le PUT vers le dépôt — plus rien de calculatoire.
### Ce que coûte réellement un enregistrement — mesuré 2026-08-21
```
save: publishing against the snapshot in hand
save: decrypt+patch in 23 ms
save: re-encrypt in 23 ms
save: PUT 267700 chars in 2745 ms
```
Le calcul pèse 46 ms. **Tout le reste est le conteneur qui monte** : 267 Ko à
chaque écriture, dont l'essentiel est une pièce jointe de 116 Ko. C'est le
plancher de ce format — le conteneur est monolithique, il n'y a pas de
« publier seulement l'entrée modifiée ».
Le tirage préalable, lui, a disparu : il coûtait 3,2 s et ne servait qu'à
s'assurer de modifier la dernière version publiée, ce que le sha prouve
gratuitement. Avec la clé retenue, l'enregistrement est passé de 5–7 s à ~2,8 s.
#### À REPRENDRE : les 2,7 s qui restent
Elles sont entièrement l'envoi. Et il part **deux fois encodé** : l'API de
contenus de Gitea exige du base64 dans du JSON, alors que le conteneur est déjà
du base64 — 267 700 caractères deviennent ~357 Ko sur le réseau, à ~130 Ko/s.
Le poids vient d'une seule chose : **une pièce jointe de 116 Ko**, soit ~155 Ko
dans le JSON en clair sur ~200 Ko de contenu — **environ 78 % de ce qui voyage
à chaque écriture**, pour une image que le téléphone ne sait même pas ouvrir.
Les onze entrées pèsent le reste.
Observé à l'usage, une fois la clé retenue et le tirage supprimé : **1–2 s**
quand la liaison est bonne, **jusqu'à 4–5 s** quand elle l'est moins. L'écart
est celui du réseau, pas du code — les 46 ms de chiffrement sont les mêmes dans
les deux cas.
Trois voies, par coût croissant :
1. **Sortir la pièce jointe du coffre synchronisé** (depuis le poste). Aucune
ligne de code, l'enregistrement tombe sous la seconde — et ça vérifie le
diagnostic ci-dessus.
2. **Publier en arrière-plan** : l'entrée apparaît aussitôt, l'envoi continue
derrière. Ne rend rien plus rapide, supprime l'attente. Contrepartie réelle :
aujourd'hui « enregistré » veut dire « publié », et fermer l'application
pendant l'envoi perdrait l'entrée. À faire en écrivant le cache local AVANT
l'envoi, ce qui met le cache en avance sur le distant — la situation même
contre laquelle le sha protège, donc à traiter avec soin.
3. **Comprimer avant de chiffrer** : ~30 %, mais il faut changer le format des
deux côtés, poste compris. Disproportionné.
**Conséquence : le chemin 409 est devenu porteur.** C'est lui,
désormais, qui garantit qu'un enregistrement n'écrase pas un autre appareil,
et il n'a toujours jamais tourné. Le provoquer : synchroniser depuis le poste,
puis enregistrer depuis le téléphone sans synchroniser d'abord.
### Le clavier : personne ne dit ce qu'il recouvre — 2026-08-21
Le bas du formulaire restait sous le clavier, sans moyen d'y accéder. **Trois
sources ont été interrogées sur ce qu'il recouvre, les trois répondent comme
s'il n'existait pas** :
- `visualViewport.height` — identique à `innerHeight`, clavier levé ;
- `innerHeight` — inchangé, la fenêtre reste en plein écran ;
- `android:windowSoftInputMode="adjustResize"` — sans effet : Android l'ignore
pour une fenêtre plein écran, ce qu'est celle de FMX. Retiré, pour ne pas
laisser croire qu'il porte quelque chose.
La mesure qui a tranché, clavier levé :
```
focus: inner 719 visual 719 | box client 659 scroll 659 | body=on-detail
```
`client` = ce que la boîte montre, `scroll` = ce qu'elle contient. Égaux :
aucun débordement, donc rien à faire défiler. Le clavier recouvrait le bas
d'une page qui se croyait en pleine hauteur.
**On a donc cessé de demander.** Le temps de la saisie, le panneau réserve 60 %
de hauteur sous son contenu — c'est du débordement, donc ça défile — et le champ
focalisé remonte en haut, mais **seulement s'il est dans le bas du panneau** :
un champ déjà visible ne doit pas sauter sous l'œil pour rien.
Deux corrections antérieures, posées par raisonnement sans instrument,
reposaient toutes deux sur une mesure qui vaut zéro ici. La leçon vaut d'être
écrite : sur cet écran, **instrumenter d'abord**.
### Le code se changeait tout seul — corrigé 2026-08-21
Le code du cache est passé de `0000` à `00000000` sans que personne ne le
décide. Cause : **le code tapé et le code sous lequel le cache est scellé
étaient la même variable**, et toute écriture du cache rescellait donc sous ce
qui traînait dans le champ. Une synchronisation — qui ne demande pourtant aucun
code — suffisait à le changer, en silence.
Les deux sont séparés maintenant. Seule **l'ouverture du coffre** touche au
sceau, parce que c'est le seul moment où l'on sait que l'utilisateur connaît le
code courant : le déchiffrement vient de le prouver. Tout le reste — sync,
rafraîchissement de fond, enregistrement d'entrée — réécrit le cache sous le
code déjà en place.
D'où un second champ, *new unlock code*, visible seulement quand un code existe :
il faut ouvrir avec l'actuel pour que le nouveau prenne. Sans lui, la correction
aurait enfermé l'appareil sous le code posé par accident.
### Le chemin d'écriture, vérifié de bout en bout — 2026-08-21
Le poste, après synchronisation :
```
Sync complete - pulled 4 new . 1 updated . 1 deleted . pushed 11 entries
```
Les trois verbes d'un coup, et depuis l'autre bout : **créer** (4), **modifier**
(1), **supprimer** (1). Le `1 deleted` est celui qui comptait — la disparition
locale ne prouvait rien, c'est la pierre tombale qui devait voyager.
Les étapes 1 à 3 de cette section sont donc closes.
### Le conflit rend 422, pas 409 — corrigé 2026-08-21
Le chemin de reprise a enfin tourné, et il ne reconnaissait pas ce qu'il
recevait. Un poste qui publie entre notre lecture et notre écriture nous vaut un
**422**, pas un 409 : l'enregistrement échouait au lieu de reprendre.
Gitea rend 409 quand le sha envoyé ne correspond plus, et 422 quand il manque ou
que la demande est jugée irrecevable — dont le cas « ce fichier existe déjà »
d'une création. Les deux disent la même chose pour nous : **le sha n'est plus le
bon**. Relire et recommencer est la bonne réponse aux deux, et elle est sans
danger — la reprise republie avec le sha frais, donc elle n'écrase jamais rien ;
un corps réellement mal formé échouerait de nouveau et sortirait au bout de
trois essais, avec le texte de Gitea.
C'est exactement ce qu'un chemin jamais exercé cache : il était écrit, il
compilait, il était même commenté — et il ne se déclenchait pas.
### Ce qui devient facultatif
Sans dépôt configuré, *Sync* n'a rien à faire : les réglages du serveur
restent, mais l'application ne doit plus se comporter comme si le réseau était
la seule source.
---
## Ordre proposé
1. **Vérifier §1 sur téléphone** — c'est ce qui valide le travail déjà fait, et
rien d'autre ne peut le remplacer.
2. §2 item 4 (presse-papiers sensible) puis 6 (verrouillage sur inactivité).
3. §3 item 1, une fois tranché où atterrissent les pièces jointes.
4. §4 à partir de l'étape 2, si l'écriture vaut son coût.
---
## Vérifier le code Android sans le déployer
Le compilateur croisé va jusqu'à l'édition de liens, qui échoue seulement
parce que `ldandroid.exe` appartient au NDK et n'est pas dans le `PATH` :
```bash
"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dccaarm64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\android64\release" pmserverApk.dpr
```
`F1026 File not found: 'ldandroid.exe'` **sans erreur au-dessus** signifie que
tout a compilé. C'est ce qui permet de valider des déclarations JNI sans
attendre un APK — le pont construit ses signatures à partir des types déclarés,
et une signature fausse ne se voyait jusqu'ici qu'à l'exécution. Cela ne
prouve pas qu'une signature *correspond* à une méthode Java réelle, seulement
qu'elle est cohérente : le déploiement reste le juge final.