# 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.**
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](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.
## 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"`** du manifeste, et servir
l'endpoint en HTTPS avec un vrai certificat. L'attribut est marqué
TEST-ONLY ; le jeton Bearer circule à chaque appel. **Gardé pour l'instant** :
le retirer casserait l'essai local par `adb reverse`, qui passe en clair.
Voir §6 : passer par Gitea réglerait ce point sans rien héberger de plus.
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.** On n'a que le déchiffrement, des deux côtés
(JNI et CNG). Il faut aussi un sel de 32 octets et un IV de 12 octets tirés
d'un générateur cryptographique — `SecureRandom` côté Android,
`BCryptGenRandom` côté Windows. **Jamais** `Random`.
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.** Modifier un champ, marquer l'entrée sale, bumper
`updated_at` en UTC (`toISOString` côté JS, `NowUTC` côté Delphi — mélanger
heure locale et UTC a déjà décalé l'arbitrage une fois).
6. **Le conflit 412** — retirer, refusionner, renvoyer, avec un nombre
d'essais borné. Le poste Windows fait exactement cela, en trois essais.
### 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
```
Réponse : `{ "content": "", "encoding": "base64", "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
{ "message": "sync", "content": "", "sha": "",
"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
Un jeton d'accès personnel **limité à ce dépôt**, droits `repo` en écriture. 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.
**À faire si on s'y engage** : un `PMA.SyncClient` qui parle cette API (le GET
authentifié existe déjà), la même chose côté poste Windows en remplacement de
WebDAV, 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.
## 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.