Files

62 KiB
Raw Permalink Blame History

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 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

Points 1, 2, 3 et 5 validés sur appareil (voir plus bas). Ce qui suit est l'énoncé d'origine, gardé pour le point 4 — le seul qui reste.

L'émulateur n'a ni empreinte ni écran de verrouillage, donc PMA.Store est retombé sur la clé sans contrainte à chaque essai :

  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.

Toujours pas exercé, et largement désamorcé depuis. Le code de déverrouillage (§7) scelle le cache sous VER_PIN, une clé AES sans contrainte qu'aucun changement de verrouillage d'écran n'invalide : tant qu'un code est posé, la branche authentifiée ne s'exécute pas. C'est précisément pour cela que le code a été construit — un changement de schéma détruisait le cache. Le vérifier demanderait donc de retirer le code d'abord.


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.

Le diagnostic d'origine, conservé pour la trace

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.

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, 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 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 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, 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. 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.

    Suite, 2026-08-21 — vider n'est pas copier. Une vignette blanche « copié » apparaissait longtemps après le geste, une fois sur deux. Elle venait de nous : l'effacement automatique à 45 s écrivait une chaîne vide via setPrimaryClip, ce que le système lit comme une copie et annonce comme telle. Le commentaire du code affirmait que « le résultat visible est le même » ; il ne l'est pas. clearPrimaryClip depuis l'API 28, avec le repli au-dessous — repli inoffensif par construction, la vignette n'existant qu'à partir d'Android 12.

    Une fois sur deux seulement, parce que le minuteur ne vide que si le presse-papiers contient toujours notre texte : copier autre chose entre-temps annule l'effacement, donc la vignette.

    Ce qui reste hors de portée : le bouton Envoyer à l'appareil de cette vignette appartient à Android. Le drapeau masque le contenu, il ne retire pas l'action.

  5. Sortir l'URL et le jeton du code source — fait. Les deux vivent dans settings.bin, chiffré par le Keystore, dès que l'appareil les a une fois enregistrés ; le mot de passe de sync y était déjà. Le préremplissage depuis PMA.LocalConfig.inc subsiste en débogage seulement — mesuré absent d'une release, en UTF-8 comme en UTF-16 (voir §5). Il reste par décision, pas par oubli : hors dépôt, hors release, et le jeton n'ouvre qu'un dépôt d'essai.

  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 — enregistrables depuis le 2026-08-22.

    Deux questions, et le code n'était que la seconde.

    Où le fichier atterrit. Le répertoire privé ne sert à rien — aucun gestionnaire de fichiers ne l'ouvre, et il part avec la désinstallation. Le stockage externe direct est fermé depuis Android 10. Restaient trois portes : MediaStore → Téléchargements, le sélecteur de destination (ACTION_CREATE_DOCUMENT), le partage (ACTION_SEND). MediaStore, parce que c'est la seule qui ne demande ni permission ni écran supplémentaire : depuis l'API 29, une application peut écrire ce qu'elle a elle-même inséré. Le sélecteur exigerait un retour d'activité, que FMX rend pénible ; le partage, un FileProvider au manifeste. En dessous de l'API 29 il faudrait WRITE_EXTERNAL_STORAGE — précisément l'une des dix permissions retirées en §5. On refuse et on le dit.

    Où retrouver le contenu. TVault le jette à la lecture, et c'est voulu : une seule pièce jointe faisait passer le cache de 13 à 174 Ko. Il se relit donc dans l'instantané brut, qui est en mémoire depuis que l'enregistrement ne tire plus avant de publier — la même donnée sert maintenant deux besoins qui n'avaient rien à voir.

    Décodage et écriture hors du thread principal, sous le voile d'attente : quelques mégaoctets figeraient l'écran, et le voile ne se peindrait même pas.

    Ce qui sort de l'application sort de sa protection : le fichier atterrit en clair dans Téléchargements, lisible par tout ce qui les lit. C'est ce que l'utilisateur demande en appuyant, et le message de confirmation nomme l'endroit plutôt que de dire « enregistré ».

    Asymétrie à connaître : le téléphone ne peut pas SUPPRIMER une pièce jointe, et l'ajouter au patch ne suffirait pas. Le poste ne restaure les pièces jointes que pour une entrée nouvelle (applyRemoteSnapshot) ; pour une entrée qu'il connaît déjà, la fusion n'y touche pas. Publier un instantané amputé laisserait donc la ligne intacte dans sa base, et son buildSyncSnapshot suivant la remettrait — la suppression reviendrait d'elle-même au tirage d'après.

    Retirer une pièce jointe du coffre synchronisé se fait donc sur le poste. Le téléphone, lui, sait maintenant l'enregistrer avant, ce qui rend l'opération sans perte.

    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, servie par 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, 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 restait ouvert a été fermé depuis (voir §7) : le 409 a tourné — c'était un 422 —, tous les champs se modifient, et créer, dupliquer et supprimer existent avec leurs pierres tombales.

Il reste une seule chose, et c'est un choix, pas un manque : pas d'édition hors ligne. Le téléphone modifie l'instantané qu'il tient et le publie ; sans réseau, Save échoue. C'est le prix de ne pas avoir de fusion à écrire dans le chemin d'écriture courant. Une file de modifications en attente la ramènerait, avec l'arbitrage qui va avec — et MergeSnapshots existe désormais, ce qui en réduit le coût.

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

Fait — 2026-08-21

  • Icône, nom, version : « Coffre », versionCode=1, versionName=1.0.0.

  • Permissions : dix devenues trois. Le manifeste demandait localisation fine et grossière, caméra, téléphone, état du téléphone et stockage externe — les défauts de Delphi, aucun utilisé. Sur un coffre à mots de passe ce sont exactement les mauvais signaux : celui qui lit la fiche y voyait une demande de position et d'appareil photo sans explication possible. La liste est désormais écrite à la main dans le modèle : INTERNET, USE_BIOMETRIC, USE_FINGERPRINT. Piège rencontré : nommer le marqueur de substitution dans le commentaire qui explique son retrait suffit à le faire revenir — la substitution est textuelle et ne regarde pas si elle tombe dans un commentaire.

  • Les secrets de banc d'essai ne sont plus compilés en release. PMA.LocalConfig.inc n'est inclus qu'en débogage ; en release les deux constantes valent la chaîne vide. Vérifié dans le binaire, en UTF-8 et en UTF-16 — Delphi range ses chaînes en UTF-16, et un grep ASCII rendait zéro des deux côtés, donc ne prouvait rien :

    debug    jeton=1  motdepasse=1
    release  jeton=0  motdepasse=0
    

    Le fichier reste en place, et c'est décidé (2026-08-21) : il n'entre plus dans une release, il est ignoré par git, et le jeton qu'il contient n'ouvre qu'un dépôt d'essai. Le garder évite de retaper quarante caractères à chaque réinstallation du banc. Ce qui vaudrait révocation, c'est un jeton donnant accès à autre chose que ce dépôt — pas celui-ci.

  • Le journal quitte l'écran d'accueil en release, sans quitter l'application : un appui long de 1,5 s sur la ligne d'état le ramène. Il ne contient rien de secret — le jeton n'y figure que par empreinte — mais un bouton « Log » permanent est du bruit de développement, et les diagnostics doivent rester atteignables le jour où quelque chose cloche à distance.

  • La configuration Release compilait pas du tout, et personne ne le savait : les déclarations des deux auto-contrôles étaient sous {$IFDEF DEBUG}, leurs implémentations non. La classe ne les déclarant plus, leurs corps devenaient des membres inconnus. Invisible tant qu'on ne construit qu'en débogage — ce qui a été le cas jusqu'ici.

La signature — outillée le 2026-08-22

build-apk.ps1 sait construire et signer une release. Pas par Delphi :

.ndroid-clientuild-apk.ps1 -Release `
    -Keystore C:\cles\pmserver-release.jks -Alias pmserver

Le magasin se crée une fois, hors du dépôt :

keytool -genkeypair -v -keystore pmserver-release.jks         -alias pmserver -keyalg RSA -keysize 4096 -validity 10000

Le mot de passe ne touche rien de durable. Il est demandé par Read-Host -AsSecureString — rien ne s'affiche, rien ne reste dans l'historique de la console —, passé à apksigner par une variable d'environnement, et retiré dans un finally même si le script échoue.

Trois raisons de ne pas passer par Project > Options > Provisioning : le .dproj est versionné, paclient --apksign met le mot de passe sur la ligne de commande où n'importe quel processus de la machine le lit, et l'outillage doit rester lisible dans le dépôt plutôt que caché dans un réglage d'IDE. apksigner appelé en direct accepte --ks-pass env:.

Le script affiche le certificat utilisé après signature. Une release signée par mégarde avec la clé de débogage s'installe et fonctionne : rien ne la distingue à l'usage, et on ne s'en aperçoit qu'au refus de la première mise à jour.

Il n'installe pas la release : Android refuse une signature différente sur un paquet déjà présent, et désinstaller effacerait le cache local et les réglages de l'appareil. À faire sciemment, pas en effet de bord d'un script.

*.jks et *.keystore sont ignorés par git. Le magasin se sauvegarde ailleurs : le perdre interdit toute mise à jour de l'application déjà installée.


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 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) change l''en-tête et le type de contenu, et 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. Dossier et secret 2FA — ajoutés le 2026-08-21. Le dossier n'avait pas de ligne à lui : il partageait « Folder / tags » avec les étiquettes, donc rien n'était modifiable. Il se choisit dans la liste des dossiers connus plutôt que de se taper — une faute de frappe rangerait l'entrée dans un dossier que rien n'affiche, ni ici ni sur le poste, et elle semblerait disparue. Les étiquettes restent en lecture seule : c'est un tableau dans l'instantané, et l'écrire comme une chaîne casserait sa forme pour le poste.

    Le secret 2FA a désormais sa ligne, masquée comme un mot de passe. Le code avait sa case depuis longtemps, mais il est calculé et renouvelé toutes les trente secondes ; c'est le secret qui se pose, se change et se retire, et rien ne permettait de le faire depuis le téléphone.

    Icônes plutôt que mots sur copier-utilisateur, copier-mot-de-passe, copier, montrer et modifier — quatre mots par ligne laissaient peu de place au nom de l'entrée. Sans pastille : le dessin seul, cible maintenue à 44 px. Chaque icône porte son aria-label.

    Piège CSS, de la même famille : ne jamais donner à un état du body le nom d'une classe que porte déjà un élément. Le voile d'attente s'appelait busy et l'état aussi — donc .busy { display: none } s'appliquait au body, et la page entière s'éteignait dès qu'une opération commençait. Les états portent des noms que rien d'autre ne porte (is-busy, typing, selecting, on-list, on-detail).

    Piège, à retenir avant d'ajouter le prochain bouton-icône : la cible de l'appui devient le <svg> ou l'un de ses traits, jamais le bouton. Lire ev.target.getAttribute('data-…') rend alors null et le gestionnaire retombe sur le cas par défaut — ici, ouvrir l'entrée au lieu de copier. Le doigt tombant tantôt sur le glyphe tantôt sur la marge, cela ne ratait qu'une fois sur deux, ce qui est la pire des pannes à diagnostiquer. Remonter au bouton (closest) avant de lire quoi que ce soit.

    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.

    Et elle ne va pas non plus dans la corbeille du poste — vérifié dans le code serveur : recevoir une pierre tombale déclenche DELETE FROM vault_entries, un effacement dur. Supprimer depuis le téléphone retire donc la ligne des deux côtés, en contournant la corbeille. Le seul filet est la sauvegarde du poste. Il n'y a rien à afficher comme corbeille ici : le téléphone n'en reçoit jamais le contenu.

    La corbeille, c'est l'historique du dépôt. Chaque publication est un commit Gitea — l'API des contenus en crée un à chaque écriture, avec le message que le téléphone envoie (entries deleted from phone…). L'instantané d'avant la suppression est donc toujours là, consultable dans l'interface web du dépôt et récupérable par un import. Ce n'est pas un écran, mais c'est un vrai filet — et il rattrape aussi ce qu'une corbeille ne rattraperait pas : une modification malheureuse, un import raté.

    L'action vit dans le menu de l'entrée (les trois points, en tête de détail), et non plus en bouton pleine largeur au bas de chaque écran : rouge et présent en permanence pour un geste qu'on fait trois fois par an. Deux gestes délibérés — ouvrir, puis confirmer — et rien qui traîne sous les yeux le reste du temps.

  4. Réconcilier avec la synchronisation — fait le 2026-08-21.

    Jusqu'ici les deux situations ne se rencontraient pas : un coffre local n'avait pas de dépôt, un coffre tiré d'un dépôt n'avait pas de conteneur local. Le jour où les deux existent, la règle « le conteneur local fait autorité » fait que les enregistrements n'atteignent jamais le dépôt — et les deux divergent en silence, la pire des issues.

    Sync fond donc les deux, une fois, puis efface le conteneur local : le dépôt devient l'autorité et l'appareil se comporte dès lors comme n'importe quel autre. C'est ce qui rend la divergence impossible plutôt qu'improbable. L'ordre compte — on publie d'abord, on efface ensuite ; l'inverse perdrait le coffre entier si la publication échouait.

    Le distant sert de base, jamais le local. Il porte ce que le téléphone ne sait pas reconstruire : le contenu des pièces jointes, l'avatar, la couleur des dossiers, et ce que le poste ajoutera demain. Repartir du local amputerait tout cela, et le poste adopterait la version amputée au tirage suivant — la perte silencieuse contre laquelle PMA.VaultPatch existe.

    L'arbitrage est celui du poste, terme à terme : dernier écrit gagnant sur updated_at, une pierre tombale l'emporte sur une entrée plus ancienne qu'elle, mais pas sur une entrée modifiée après — celle-là a été ressuscitée volontairement. Les horodatages étant de l'ISO 8601 UTC des deux côtés, millisecondes comprises, l'ordre lexicographique est l'ordre chronologique : aucune date à analyser.

    Dépôt vide : rien à fusionner, on publie. Le 404 de Gitea étant ambigu — fichier absent ou dépôt qu'on n'a pas le droit de voir — on ne cherche pas à trancher : publier tranche, un jeton sans droit échouant au PUT avant que rien n'ait été effacé.

    MergeSelfCheck vérifie les quatre règles au démarrage en débogage. La fusion ne tourne jamais contre un vrai coffre avant d'avoir tourné là.

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.

Résolu le 2026-08-22 — par la voie 1, sans une ligne de code

La pièce jointe retirée du coffre depuis le poste, puis une synchronisation :

HTTP 200 in 597 ms
received 49268 bytes

266 299 → 49 268 octets. Elle pesait donc 217 031 octets, soit 81 % de ce qui voyageait à chaque écriture — l'estimation disait 78 %. Le tirage tombe de 3244 ms à 597 ms, et l'envoi suit la même proportion.

Le téléphone sachant désormais enregistrer une pièce jointe dans Téléchargements (§3), l'opération s'est faite sans rien perdre. La leçon vaut d'être gardée : le poids d'un coffre synchronisé, ce sont ses pièces jointes, et le conteneur étant monolithique, chacune se paie à chaque écriture de n'importe quelle entrée.

Et l'enregistrement, mesuré ensuite :

save: decrypt+patch in 6 ms
save: re-encrypt in 15 ms
save: PUT 50457 chars in 1133 ms

~2,8 s → ~1,15 s. Mais la charge a diminué de 5,3× quand le temps n'a diminué que de 2,4× : le reste n'est plus la taille, c'est le trajet — poignée de main TLS, aller-retour, création du commit par Gitea. Un coût fixe d'environ six à huit dixièmes de seconde, quelle que soit la charge.

C'est le plancher de ce transport, et ça clôt la question : réduire encore le coffre n'achèterait presque rien. Les deux autres voies (publier en arrière-plan, comprimer) ne valent donc plus la peine — la première ne supprimerait qu'une seconde d'attente au prix de « enregistré » cessant de vouloir dire « publié », la seconde changerait le format des deux côtés pour grappiller sur une part devenue minoritaire.

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.

Vérifié ensuite, de bout en bout :

save: PUT 268162 chars in 813 ms
save: 422 - another device published, re-reading
save: attempt 2 - GET
  key derived in 4397 ms
save: decrypt+patch in 4414 ms
save: PUT 268060 chars in 1710 ms
save: published, new sha bc44e5e6...

Conflit détecté, instantané frais retiré, modification appliquée dessus, publication acceptée. Rien d'écrasé. §4 est close.

Une chose apprise au passage : key derived in 4397 ms au second essai. Le poste avait rechiffré son conteneur avec un sel neuf, donc la clé en main ne convenait plus — c'est précisément le cas contre lequel la réutilisation se garde, et on le voit se déclencher pour la première fois. Coût : une dérivation par publication du poste, pas davantage, la nouvelle clé étant retenue à son tour.

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.


Ce qui reste — au 2026-08-22 (soir)

Le premier point n'est pas du code. Cinq séries du scénario (TESTS.md) portent sur du code qui n'a jamais tourné : l'enregistrement hors connexion et la fusion (A, B), le témoin du dépôt (E), les réglages et le retrait du code (F), les icônes (G). Elles ont été écrites d'affilée sans qu'aucune ne touche l'appareil, et cette séance a montré une douzaine de fois que les défauts se révèlent à l'usage — dont deux pertes de données silencieuses trouvées en relisant. A et B d'abord : elles touchent au chemin d'écriture, où un défaut coûte des données ; le reste ne coûte que de l'agacement.

Ensuite, par ordre :

  1. La signature (§5) — l'outillage est prêt, le magasin de clés existe, le chemin -Release n'a jamais tourné. Seul point qui bloque une distribution.
  2. Deux constats de relecture écartés sciemment, avec leur raison, dans TESTS.md : le voile d'attente libéré dans un except plutôt que dans un finally, et ParseJSONValue … as TJSONObject qui fuit sur une entrée qui n'est pas un objet.
  3. §1 point 4 — l'invalidation de la clé par enrôlement d'une empreinte. Jamais exercée, et largement désamorcée par le code de déverrouillage : tant qu'un code est posé, cette branche ne s'exécute pas.
  4. §3 point 6 — la liste virtuelle, le jour où la reconstruction à chaque frappe se voit. Marquée ponytail: dans la page, pas avant.
  5. L'historique du dépôt grossit sans fin : chaque publication est un instantané complet. À ~50 Ko et quelques écritures par jour, cela reste modeste des années ; une pièce jointe le change du tout au tout. Une purge périodique était déjà notée en §6.

Une limite assumée, notée avec sa raison plutôt que portée comme un manque : le téléphone ne peut pas supprimer une pièce jointe — le poste ne touche pas aux pièces jointes d'une entrée qu'il connaît déjà, donc un instantané amputé serait défait au tirage suivant (§3).

Plus une limite : l'édition hors connexion, longtemps listée ici, existe depuis ce soir.


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 :

"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.