feat(android): folder filter, icons, sort, template, updated_at, FLAG_SECURE

Works through the plan's §2 and §3 items that need no hardware and no
platform decision.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-15 11:23:29 +01:00
parent 3bb6f6f903
commit 9a4170c44f
7 changed files with 288 additions and 42 deletions
+7
View File
@@ -18,7 +18,14 @@
reseau opaque. A RETIRER avant diffusion : l'endpoint de production reseau opaque. A RETIRER avant diffusion : l'endpoint de production
est en HTTPS, et le jeton Bearer circule dans un en-tete a chaque est en HTTPS, et le jeton Bearer circule dans un en-tete a chaque
appel. --> appel. -->
<!-- allowBackup=false : le repertoire prive contient vault.bin, c'est-a-dire
le coffre entier. Il est chiffre par une cle qui ne quitte pas la puce,
donc ce qui sortirait serait inexploitable - mais il sortirait, et une
sauvegarde cloud n'a rien a faire dans le chemin d'un gestionnaire de
mots de passe. fullBackupContent et dataExtractionRules ne sont pas
necessaires une fois la sauvegarde refusee en bloc. -->
<application <application
android:allowBackup="false"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:persistent="%persistent%" android:persistent="%persistent%"
android:restoreAnyVersion="%restoreAnyVersion%" android:restoreAnyVersion="%restoreAnyVersion%"
+54 -29
View File
@@ -61,17 +61,19 @@ retéléchargé, pas rester illisible. C'est l'autre branche de la même garde.
Par ordre de gravité, pas de taille. Par ordre de gravité, pas de taille.
1. **`android:allowBackup="false"`** dans 1. ~~**`android:allowBackup="false"`**~~ — **fait**. Le répertoire privé
[AndroidManifest.template.xml](../AndroidManifest.template.xml). Une ligne. contient `vault.bin` ; la sauvegarde Google n'a rien à y faire, même si ce
Aujourd'hui absente : la sauvegarde Google peut donc emporter `vault.bin`. qui en sortirait serait chiffré par une clé restée dans la puce.
Le fichier est chiffré par une clé qui ne quitte pas la puce, donc ce qui 2. **Retirer `usesCleartextTraffic="true"`** du manifeste, et servir
sort est inexploitable — mais il sort, et le README prescrit déjà l'inverse.
2. **Retirer `usesCleartextTraffic="true"`** du même fichier, et servir
l'endpoint en HTTPS avec un vrai certificat. L'attribut est marqué l'endpoint en HTTPS avec un vrai certificat. L'attribut est marqué
TEST-ONLY dans le manifeste ; le jeton Bearer circule à chaque appel. TEST-ONLY ; le jeton Bearer circule à chaque appel. **Gardé pour l'instant** :
3. **`FLAG_SECURE`** sur la fenêtre. On masque déjà l'écran avant la capture le retirer casserait l'essai local par `adb reverse`, qui passe en clair.
des applications récentes, mais rien n'empêche une capture d'écran 3. ~~**`FLAG_SECURE`**~~ — **fait**, [PMA.ScreenGuard.pas](PMA.ScreenGuard.pas),
manuelle. `getWindow().setFlags` par JNI, quelques lignes. **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** — depuis Android 13 le système 4. **Marquer le presse-papiers sensible** — depuis Android 13 le système
affiche un aperçu de ce qui vient d'être copié, mot de passe compris. affiche un aperçu de ce qui vient d'être copié, mot de passe compris.
`ClipDescription` + extra `android.content.extra.IS_SENSITIVE`. `ClipDescription` + extra `android.content.extra.IS_SENSITIVE`.
@@ -89,17 +91,23 @@ Par ordre de gravité, pas de taille.
Rangé par ce que ça apporte réellement sur un téléphone. Rangé par ce que ça apporte réellement sur un téléphone.
1. **Pièces jointes** — elles sont dans l'instantané, personne ne les lit. 1. **Pièces jointes** — elles sont dans l'instantané, personne ne les lit.
Lister nom/taille par entrée, et enregistrer via le sélecteur système. Lister nom/taille par entrée, et enregistrer. **Décision à prendre avant
Attention à la mémoire : `content_b64` est déjà décodé en RAM avec tout le d'écrire quoi que ce soit** : où enregistrer sur Android 10+. Le répertoire
reste de l'instantané, un coffre avec des PDF pèse lourd. privé de l'application ne sert à rien à l'utilisateur, et *Téléchargements*
2. **Filtres dossier et étiquette.** `TVault.Search` ne prend qu'une requête passe par `MediaStore` ou un `Intent` de partage. C'est cette question, pas
texte ; ajouter deux paramètres facultatifs et une rangée de puces le code, qui bloque. Attention aussi à la mémoire : `content_b64` est déjà
au-dessus de la liste. Les dossiers viennent du tableau `folders`. décodé en RAM avec le reste de l'instantané, un coffre avec des PDF pèse.
3. **Icônes** (`icon_b64`) en tête de ligne, à la place des initiales. 2. ~~**Filtre dossier**~~ — **fait**, liste déroulante à côté de la recherche,
Purement cosmétique, mais c'est ce qui rend une liste balayable. alimentée par le tableau `folders`, et le choix survit à un rafraîchissement
4. **`template`** — afficher le sous-type (carte, clé SSH…) comme le fait le de fond. **Pas de filtre d'étiquette** : la recherche texte couvre déjà les
poste Windows, et adapter les intitulés d'une carte bancaire. étiquettes, en taper le nom suffit.
5. **Tri.** L'ordre actuel est celui de l'instantané. Par nom, au minimum. 3. ~~**Icônes**~~ (`icon_b64`) — **fait**, décodées une fois par coffre et non à
chaque frappe (la liste est reconstruite à chaque caractère). Une icône SVG
n'est pas chargeable par FMX : l'entrée retombe sur ses initiales.
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. **`TListView` à la place de `TListBox`** le jour où la reconstruction à 6. **`TListView` à la place de `TListBox`** le jour où la reconstruction à
chaque frappe se voit. Marqué `ponytail:` dans le code, pas avant. chaque frappe se voit. Marqué `ponytail:` dans le code, pas avant.
@@ -128,9 +136,8 @@ d'interface d'édition.
### Dans l'ordre ### Dans l'ordre
1. **`updated_at` dans `TVaultEntry`.** Rien n'est arbitrable sans lui. À faire 1. ~~**`updated_at` dans `TVaultEntry`**~~ — **fait**, et déjà affiché comme
même si l'écriture est repoussée : la lecture peut déjà afficher « modifié *Modifié le* dans le détail. Rien n'était arbitrable sans lui.
le… ».
2. **AES-GCM en chiffrement.** On n'a que le déchiffrement, des deux côtés 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 (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, d'un générateur cryptographique — `SecureRandom` côté Android,
@@ -171,8 +178,26 @@ tombstones ni génération d'uuid.
## Ordre proposé ## Ordre proposé
1. Vérifier §1 sur téléphone — c'est ce qui valide le travail déjà fait. 1. **Vérifier §1 sur téléphone** — c'est ce qui valide le travail déjà fait, et
2. §2 items 1 et 2 : deux lignes de manifeste, et le risque le plus concret. rien d'autre ne peut le remplacer.
3. §3 items 1 à 3 : pièces jointes, filtres, icônes. Le client devient 2. §2 item 4 (presse-papiers sensible) puis 6 (verrouillage sur inactivité).
réellement utilisable au quotidien. 3. §3 item 1, une fois tranché où atterrissent les pièces jointes.
4. §4 étape 1 seule (`updated_at`), puis décider si l'écriture vaut son coût. 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.
+48
View File
@@ -0,0 +1,48 @@
unit PMA.ScreenGuard;
{
FLAG_SECURE : interdit la capture d'ecran et l'apparition du contenu dans
l'apercu des applications recentes.
COMPLEMENTAIRE, pas redondant, avec le masquage sur WillBecomeInactive :
celui-ci traite l'apercu des recentes, celui-la traite en plus les captures
manuelles, l'enregistrement d'ecran et la duplication vers un ecran externe -
qu'aucun code applicatif ne peut intercepter.
ACTIF EN RELEASE SEULEMENT. En debug le drapeau empeche aussi les captures
d'ecran de developpement, ce qui rend le diagnostic penible pour un gain nul
sur un appareil de test. Le verifier demande donc une compilation Release.
}
interface
/// A appeler une fois, au demarrage, depuis le thread principal.
procedure SecureScreen;
implementation
{$IF DEFINED(ANDROID) AND NOT DEFINED(DEBUG)}
uses
// System.SysUtils : sans elle, TAndroidHelper.GetJActivity reste un appel
// non expanse et le compilateur le signale a chaque usage.
System.SysUtils, Androidapi.Helpers, Androidapi.JNI.App,
Androidapi.JNI.GraphicsContentViewText, FMX.Helpers.Android;
procedure SecureScreen;
begin
// setFlags touche la fenetre : thread d'interface obligatoire.
CallInUIThread(
procedure
begin
TAndroidHelper.Activity.getWindow.setFlags(
TJWindowManager_LayoutParams.JavaClass.FLAG_SECURE,
TJWindowManager_LayoutParams.JavaClass.FLAG_SECURE);
end);
end;
{$ELSE}
procedure SecureScreen;
begin
// Rien a faire : ni Windows ni une compilation de debogage.
end;
{$ENDIF}
end.
+52 -4
View File
@@ -25,6 +25,14 @@ type
TVaultEntry = record TVaultEntry = record
Uuid, Title, Site, Username, Password, Folder, Tags, Kind, Totp: string; Uuid, Title, Site, Username, Password, Folder, Tags, Kind, Totp: string;
/// Sous-type (carte, cle SSH, serveur...). Vide pour un identifiant ou
/// une note ordinaire.
Template: string;
/// Icone en data URI. Non chiffree, purement cosmetique.
IconB64: string;
/// ISO 8601 UTC. Sert d'abord a l'affichage ; ce sera l'arbitre de la
/// fusion le jour ou le client ecrira.
UpdatedAt: string;
Fields: TArray<TVaultField>; Fields: TArray<TVaultField>;
Deleted: Boolean; Deleted: Boolean;
/// Le corps d'une note EST son mot de passe cote stockage : il n'y a pas /// Le corps d'une note EST son mot de passe cote stockage : il n'y a pas
@@ -39,6 +47,7 @@ type
TVault = class TVault = class
private private
FEntries: TArray<TVaultEntry>; FEntries: TArray<TVaultEntry>;
FFolders: TArray<string>;
FUsername: string; FUsername: string;
FExportedAt: string; FExportedAt: string;
public public
@@ -47,12 +56,22 @@ type
/// Efface le contenu. Appele au verrouillage. /// Efface le contenu. Appele au verrouillage.
procedure Clear; procedure Clear;
/// Indices des entrees correspondant a AQuery, dans l'ordre d'affichage. /// Indices des entrees correspondant a AQuery, TRIES par nom affiche -
/// l'ordre de l'instantane est celui de la base, donc arbitraire a l'oeil.
/// Requete vide = tout ce qui n'est pas en corbeille. /// Requete vide = tout ce qui n'est pas en corbeille.
///
/// La recherche porte sur le nom, le site, l'identifiant et les tags - /// La recherche porte sur le nom, le site, l'identifiant et les tags -
/// PAS sur le mot de passe : personne ne cherche par mot de passe, et le /// PAS sur le mot de passe : personne ne cherche par mot de passe, et le
/// comparer allongerait inutilement son sejour en memoire de travail. /// comparer allongerait inutilement son sejour en memoire de travail.
function Search(const AQuery: string; AIncludeTrash: Boolean = False): TArray<Integer>; /// Comme les etiquettes sont couvertes par la recherche texte, il n'y a
/// pas de filtre d'etiquette separe : taper son nom suffit.
///
/// AFolder vide = tous les dossiers.
function Search(const AQuery: string; const AFolder: string = '';
AIncludeTrash: Boolean = False): TArray<Integer>;
/// Dossiers declares par l'instantane, dans son ordre.
property Folders: TArray<string> read FFolders;
function Count: Integer; function Count: Integer;
function LiveCount: Integer; function LiveCount: Integer;
@@ -66,7 +85,7 @@ type
implementation implementation
uses uses
System.Character; System.Character, System.Generics.Defaults;
{ TVaultEntry } { TVaultEntry }
@@ -88,6 +107,7 @@ end;
procedure TVault.Clear; procedure TVault.Clear;
begin begin
SetLength(FEntries, 0); SetLength(FEntries, 0);
SetLength(FFolders, 0);
FUsername := ''; FUsername := '';
FExportedAt := ''; FExportedAt := '';
end; end;
@@ -155,6 +175,21 @@ begin
FExportedAt := LRoot.GetValue<string>('exported_at', FExportedAt := LRoot.GetValue<string>('exported_at',
LRoot.GetValue<string>('snapshot_at', '')); LRoot.GetValue<string>('snapshot_at', ''));
// Dossiers : l'instantane porte {name, color, icon}, seul le nom sert ici.
// Un instantane ancien peut ne pas avoir le tableau du tout.
if LRoot.TryGetValue<TJSONArray>('folders', LArr) then
begin
SetLength(FFolders, LArr.Count);
N := 0;
for I := 0 to LArr.Count - 1 do
begin
if not (LArr.Items[I] is TJSONObject) then Continue;
FFolders[N] := (LArr.Items[I] as TJSONObject).GetValue<string>('name', '');
if FFolders[N] <> '' then Inc(N);
end;
SetLength(FFolders, N);
end;
if not LRoot.TryGetValue<TJSONArray>('entries', LArr) then if not LRoot.TryGetValue<TJSONArray>('entries', LArr) then
raise Exception.Create('Instantane sans tableau "entries"'); raise Exception.Create('Instantane sans tableau "entries"');
@@ -174,6 +209,9 @@ begin
FEntries[N].Totp := LObj.GetValue<string>('totp_secret', ''); FEntries[N].Totp := LObj.GetValue<string>('totp_secret', '');
FEntries[N].Tags := TagsToString(LObj.GetValue('tags')); FEntries[N].Tags := TagsToString(LObj.GetValue('tags'));
FEntries[N].Fields := ParseFields(LObj.GetValue('custom_fields')); FEntries[N].Fields := ParseFields(LObj.GetValue('custom_fields'));
FEntries[N].Template := LObj.GetValue<string>('template', '');
FEntries[N].IconB64 := LObj.GetValue<string>('icon_b64', '');
FEntries[N].UpdatedAt := LObj.GetValue<string>('updated_at', '');
// deleted arrive en 0/1 depuis nos conteneurs ; absent = vivant. // deleted arrive en 0/1 depuis nos conteneurs ; absent = vivant.
FEntries[N].Deleted := LObj.GetValue<Integer>('deleted', 0) = 1; FEntries[N].Deleted := LObj.GetValue<Integer>('deleted', 0) = 1;
Inc(N); Inc(N);
@@ -206,7 +244,8 @@ begin
Result := FEntries[AIndex]; Result := FEntries[AIndex];
end; end;
function TVault.Search(const AQuery: string; AIncludeTrash: Boolean): TArray<Integer>; function TVault.Search(const AQuery: string; const AFolder: string;
AIncludeTrash: Boolean): TArray<Integer>;
var var
LQ: string; LQ: string;
LRes: TList<Integer>; LRes: TList<Integer>;
@@ -230,12 +269,21 @@ begin
begin begin
E := FEntries[I]; E := FEntries[I];
if E.Deleted and not AIncludeTrash then Continue; if E.Deleted and not AIncludeTrash then Continue;
if (AFolder <> '') and not SameText(E.Folder, AFolder) then Continue;
if Matches(E) then LRes.Add(I); if Matches(E) then LRes.Add(I);
end; end;
Result := LRes.ToArray; Result := LRes.ToArray;
finally finally
LRes.Free; LRes.Free;
end; end;
// Tri sur le nom affiche, insensible a la casse et a l'accentuation faible
// de CompareText - suffisant pour une liste qu'on parcourt a l'oeil.
TArray.Sort<Integer>(Result, TComparer<Integer>.Construct(
function(const A, B: Integer): Integer
begin
Result := CompareText(FEntries[A].Display, FEntries[B].Display);
end));
end; end;
end. end.
+7
View File
@@ -91,6 +91,12 @@ le champ mot de passe et s'affiche donc sous un autre intitulé, sans masque ;
et le **code 2FA**, seule ligne vivante, rafraîchie chaque seconde et placée en et le **code 2FA**, seule ligne vivante, rafraîchie chaque seconde et placée en
tête — c'est ce qu'on vient chercher sur un téléphone. tête — c'est ce qu'on vient chercher sur un téléphone.
La liste est triée par nom — l'ordre de l'instantané est celui de la base,
arbitraire à l'œil — et porte l'icône de l'entrée quand elle en a une, décodée
une fois par coffre et non à chaque frappe. Une liste déroulante filtre par
dossier ; il n'y a **pas** de filtre d'étiquette, la recherche texte les
couvrant déjà.
Le TOTP ([PMA.Totp.pas](PMA.Totp.pas)) accepte le base32 nu comme l'URI Le TOTP ([PMA.Totp.pas](PMA.Totp.pas)) accepte le base32 nu comme l'URI
`otpauth://`, les deux circulant dans les instantanés selon la façon dont `otpauth://`, les deux circulant dans les instantanés selon la façon dont
l'entrée a été créée. Vérifié contre les six vecteurs de la RFC 6238. l'entrée a été créée. Vérifié contre les six vecteurs de la RFC 6238.
@@ -158,6 +164,7 @@ Node ouvre le fichier et pas le client, le défaut est dans le client.
| `PMA.Biometric.pas` | authentification via `FMX.BiometricAuth` | | `PMA.Biometric.pas` | authentification via `FMX.BiometricAuth` |
| `PMA.Vault.pas` | l'instantané en mémoire : lecture et recherche | | `PMA.Vault.pas` | l'instantané en mémoire : lecture et recherche |
| `PMA.Totp.pas` | codes TOTP (RFC 6238), base32 ou `otpauth://` | | `PMA.Totp.pas` | codes TOTP (RFC 6238), base32 ou `otpauth://` |
| `PMA.ScreenGuard.pas` | `FLAG_SECURE` (Release seulement) |
| `selftest.dpr` | vérification console : conteneur + TOTP | | `selftest.dpr` | vérification console : conteneur + TOTP |
| `PMA.TestVector.pas` | le vecteur embarqué, plus référencé par l'interface | | `PMA.TestVector.pas` | le vecteur embarqué, plus référencé par l'interface |
| `make-testvector.js` | régénère le vecteur avec WebCrypto | | `make-testvector.js` | régénère le vecteur avec WebCrypto |
+118 -8
View File
@@ -24,7 +24,8 @@ interface
uses uses
System.SysUtils, System.Types, System.UITypes, System.Classes, System.SysUtils, System.Types, System.UITypes, System.Classes,
System.Diagnostics, System.Rtti, System.Diagnostics, System.Rtti, System.NetEncoding,
System.Generics.Collections,
FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs, FMX.Platform, FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs, FMX.Platform,
FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types,
FMX.ScrollBox, FMX.Layouts, FMX.Edit, FMX.ListBox, FMX.ScrollBox, FMX.Layouts, FMX.Edit, FMX.ListBox,
@@ -91,8 +92,13 @@ type
// --- ecran 2 : liste // --- ecran 2 : liste
FLayList: TLayout; FLayList: TLayout;
FSearch: TEdit; FSearch: TEdit;
FFolderBox: TComboBox;
FList: TListBox; FList: TListBox;
FCount: TLabel; FCount: TLabel;
/// Icones decodees une fois par coffre, pas a chaque frappe : la liste est
/// reconstruite a chaque caractere tape, et decoder du PNG a ce rythme se
/// voit. Cle = indice dans le coffre.
FIcons: TObjectDictionary<Integer, TBitmap>;
// --- ecran 3 : detail // --- ecran 3 : detail
// Les lignes sont construites PAR ENTREE : leur nombre depend des champs // Les lignes sont construites PAR ENTREE : leur nombre depend des champs
@@ -139,6 +145,8 @@ type
procedure LockVault; procedure LockVault;
procedure OnSearchChanged(Sender: TObject); procedure OnSearchChanged(Sender: TObject);
procedure PopulateFolders;
procedure RebuildIcons;
procedure OnItemTap(const Sender: TCustomListBox; const AItem: TListBoxItem); procedure OnItemTap(const Sender: TCustomListBox; const AItem: TListBoxItem);
procedure OnRevealTap(Sender: TObject); procedure OnRevealTap(Sender: TObject);
procedure OnCopyTap(Sender: TObject); procedure OnCopyTap(Sender: TObject);
@@ -164,7 +172,8 @@ var
implementation implementation
uses uses
PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric, PMA.Totp; PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric, PMA.Totp,
PMA.ScreenGuard;
{$R *.fmx} {$R *.fmx}
@@ -185,7 +194,9 @@ var
begin begin
inherited; inherited;
Caption := 'Coffre'; Caption := 'Coffre';
SecureScreen; // avant tout affichage de contenu
FVault := TVault.Create; FVault := TVault.Create;
FIcons := TObjectDictionary<Integer, TBitmap>.Create([doOwnsValues]);
BuildDetail; // cree en dernier a l'ecran = au-dessus des autres BuildDetail; // cree en dernier a l'ecran = au-dessus des autres
BuildList; BuildList;
@@ -220,6 +231,7 @@ end;
destructor TForm8.Destroy; destructor TForm8.Destroy;
begin begin
FIcons.Free;
FVault.Free; FVault.Free;
inherited; inherited;
end; end;
@@ -299,7 +311,7 @@ end;
procedure TForm8.BuildList; procedure TForm8.BuildList;
var var
LBar: TLayout; LBar, LTop: TLayout;
LBtnLock: TButton; LBtnLock: TButton;
begin begin
FLayList := TLayout.Create(Self); FLayList := TLayout.Create(Self);
@@ -335,10 +347,24 @@ begin
FList.ItemHeight := 56; FList.ItemHeight := 56;
FList.OnItemClick := OnItemTap; FList.OnItemClick := OnItemTap;
LTop := TLayout.Create(Self);
LTop.Parent := FLayList;
LTop.Align := TAlignLayout.Top;
LTop.Height := 48;
LTop.Margins.Rect := RectF(8, 8, 8, 8);
// Pas de filtre d'etiquette : la recherche texte couvre deja les etiquettes,
// en taper le nom suffit. Les dossiers, eux, ne se devinent pas.
FFolderBox := TComboBox.Create(Self);
FFolderBox.Parent := LTop;
FFolderBox.Align := TAlignLayout.Right;
FFolderBox.Width := 140;
FFolderBox.Margins.Left := 8;
FFolderBox.OnChange := OnSearchChanged;
FSearch := TEdit.Create(Self); FSearch := TEdit.Create(Self);
FSearch.Parent := FLayList; FSearch.Parent := LTop;
FSearch.Align := TAlignLayout.Top; FSearch.Align := TAlignLayout.Client;
FSearch.Margins.Rect := RectF(8, 8, 8, 8);
FSearch.TextPrompt := 'Rechercher'; FSearch.TextPrompt := 'Rechercher';
FSearch.OnChangeTracking := OnSearchChanged; FSearch.OnChangeTracking := OnSearchChanged;
end; end;
@@ -523,6 +549,12 @@ begin
LMeta := LMeta + FCurrent.Tags; LMeta := LMeta + FCurrent.Tags;
end; end;
Add('Dossier / etiquettes', LMeta, False); Add('Dossier / etiquettes', LMeta, False);
// Le sous-type n'existe que pour les modeles (carte, cle SSH, serveur...) ;
// Add ignore une valeur vide, donc rien a tester ici.
Add('Type', FCurrent.Template, False);
// Horodatage tel quel : c'est de l'ISO 8601 UTC, lisible, et le reformater
// demanderait de decider d'un fuseau alors qu'il n'y a rien a arbitrer.
Add('Modifie le', FCurrent.UpdatedAt, False);
SetLength(FRowValues, Length(FRows)); SetLength(FRowValues, Length(FRows));
SetLength(FRowShown, Length(FRows)); SetLength(FRowShown, Length(FRows));
@@ -637,6 +669,7 @@ begin
FETag := ''; FETag := '';
FVault.Clear; FVault.Clear;
FList.Clear; FList.Clear;
FIcons.Clear;
// Les lignes du detail portent des valeurs EN CLAIR (FRows) : les detruire // Les lignes du detail portent des valeurs EN CLAIR (FRows) : les detruire
// fait partie du verrouillage, pas de l'affichage. // fait partie du verrouillage, pas de l'affichage.
ClearDetailRows; ClearDetailRows;
@@ -649,6 +682,8 @@ begin
FVault.LoadFromJson(APlainJson); FVault.LoadFromJson(APlainJson);
Log(' ' + FVault.LiveCount.ToString + ' entrees, ' + Log(' ' + FVault.LiveCount.ToString + ' entrees, ' +
FVault.TrashCount.ToString + ' en corbeille'); FVault.TrashCount.ToString + ' en corbeille');
RebuildIcons;
PopulateFolders;
// Deja dans l'application : reconstruire la liste SANS y ramener // Deja dans l'application : reconstruire la liste SANS y ramener
// l'utilisateur. Obligatoire meme s'il est dans le detail - les lignes de // l'utilisateur. Obligatoire meme s'il est dans le detail - les lignes de
// la liste portent des indices vers l'ancien coffre, et un appui ouvrirait // la liste portent des indices vers l'ancien coffre, et un appui ouvrirait
@@ -909,16 +944,88 @@ end;
Liste et recherche Liste et recherche
---------------------------------------------------------------------------- } ---------------------------------------------------------------------------- }
/// Data URI -> bitmap, ou nil. Les icones peuvent etre du SVG, que FMX ne
/// sait pas charger : l'echec est normal et se solde par les initiales.
function BitmapFromDataUri(const AUri: string): TBitmap;
var
LComma: Integer;
LStream: TBytesStream;
begin
Result := nil;
if not AUri.StartsWith('data:') then Exit;
LComma := Pos(',', AUri);
if LComma = 0 then Exit;
try
LStream := TBytesStream.Create(TNetEncoding.Base64.DecodeStringToBytes(
Copy(AUri, LComma + 1, MaxInt)));
try
Result := TBitmap.Create;
Result.LoadFromStream(LStream);
finally
LStream.Free;
end;
except
FreeAndNil(Result);
end;
end;
procedure TForm8.RebuildIcons;
var
I: Integer;
LBmp: TBitmap;
begin
FIcons.Clear;
// ponytail: tout decode a l'adoption du coffre, sur le thread principal.
// Quelques centaines de vignettes de 16 px ne se sentent pas ; deporter
// dans un thread le jour ou l'ouverture marque un temps.
for I := 0 to FVault.Count - 1 do
begin
if FVault.Entry(I).IconB64 = '' then Continue;
LBmp := BitmapFromDataUri(FVault.Entry(I).IconB64);
if LBmp <> nil then FIcons.Add(I, LBmp);
end;
end;
procedure TForm8.PopulateFolders;
var
LF, LPrev: string;
I: Integer;
begin
// Le choix courant survit a un rafraichissement de fond : le voir retomber
// sur "tous" tout seul, sans avoir rien touche, serait deroutant.
LPrev := '';
if FFolderBox.ItemIndex > 0 then
LPrev := FFolderBox.Items[FFolderBox.ItemIndex];
FFolderBox.Items.BeginUpdate;
try
FFolderBox.Items.Clear;
FFolderBox.Items.Add('Tous les dossiers');
for LF in FVault.Folders do FFolderBox.Items.Add(LF);
finally
FFolderBox.Items.EndUpdate;
end;
I := FFolderBox.Items.IndexOf(LPrev);
if I < 0 then I := 0;
FFolderBox.ItemIndex := I;
end;
procedure TForm8.OnSearchChanged(Sender: TObject); procedure TForm8.OnSearchChanged(Sender: TObject);
var var
I: Integer; I: Integer;
LItem: TListBoxItem; LItem: TListBoxItem;
E: TVaultEntry; E: TVaultEntry;
LFolder: string;
LBmp: TBitmap;
begin begin
// ponytail: refonte complete de la liste a chaque frappe. Sur un coffre de // ponytail: refonte complete de la liste a chaque frappe. Sur un coffre de
// quelques centaines d'entrees c'est imperceptible ; passer a un modele // quelques centaines d'entrees c'est imperceptible ; passer a un modele
// virtuel (TListView) le jour ou ca rame. // virtuel (TListView) le jour ou ca rame.
FFiltered := FVault.Search(FSearch.Text); LFolder := '';
if FFolderBox.ItemIndex > 0 then // 0 = tous les dossiers
LFolder := FFolderBox.Items[FFolderBox.ItemIndex];
FFiltered := FVault.Search(FSearch.Text, LFolder);
FList.BeginUpdate; FList.BeginUpdate;
try try
@@ -931,13 +1038,16 @@ begin
LItem.Text := E.Display; LItem.Text := E.Display;
LItem.ItemData.Detail := E.Username; LItem.ItemData.Detail := E.Username;
LItem.StyleLookup := 'listboxitembottomdetail'; LItem.StyleLookup := 'listboxitembottomdetail';
// Le setter copie le bitmap : le cache garde la propriete du sien.
if FIcons.TryGetValue(FFiltered[I], LBmp) then
LItem.ItemData.Bitmap := LBmp;
LItem.Tag := FFiltered[I]; // indice dans le coffre, pas dans la liste LItem.Tag := FFiltered[I]; // indice dans le coffre, pas dans la liste
end; end;
finally finally
FList.EndUpdate; FList.EndUpdate;
end; end;
if FSearch.Text.Trim = '' then if (FSearch.Text.Trim = '') and (LFolder = '') then
FCount.Text := FVault.LiveCount.ToString + ' entrees' FCount.Text := FVault.LiveCount.ToString + ' entrees'
else else
FCount.Text := Length(FFiltered).ToString + ' / ' + FCount.Text := Length(FFiltered).ToString + ' / ' +
+2 -1
View File
@@ -10,7 +10,8 @@ uses
PMA.Store in 'android-client\PMA.Store.pas', PMA.Store in 'android-client\PMA.Store.pas',
PMA.Biometric in 'android-client\PMA.Biometric.pas', PMA.Biometric in 'android-client\PMA.Biometric.pas',
PMA.Vault in 'android-client\PMA.Vault.pas', PMA.Vault in 'android-client\PMA.Vault.pas',
PMA.Totp in 'android-client\PMA.Totp.pas'; PMA.Totp in 'android-client\PMA.Totp.pas',
PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas';
{$R *.res} {$R *.res}