From 9a4170c44fbb711a44ea656ea93f651e05f29559 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:23:29 +0100 Subject: [PATCH] feat(android): folder filter, icons, sort, template, updated_at, FLAG_SECURE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AndroidManifest.template.xml | 7 ++ android-client/PLAN.md | 83 ++++++++++++------- android-client/PMA.ScreenGuard.pas | 48 +++++++++++ android-client/PMA.Vault.pas | 56 ++++++++++++- android-client/README.md | 7 ++ mainApk.pas | 126 +++++++++++++++++++++++++++-- pmserverApk.dpr | 3 +- 7 files changed, 288 insertions(+), 42 deletions(-) create mode 100644 android-client/PMA.ScreenGuard.pas diff --git a/AndroidManifest.template.xml b/AndroidManifest.template.xml index e295708..c3a20c1 100644 --- a/AndroidManifest.template.xml +++ b/AndroidManifest.template.xml @@ -18,7 +18,14 @@ reseau opaque. A RETIRER avant diffusion : l'endpoint de production est en HTTPS, et le jeton Bearer circule dans un en-tete a chaque appel. --> + ; Deleted: Boolean; /// Le corps d'une note EST son mot de passe cote stockage : il n'y a pas @@ -39,6 +47,7 @@ type TVault = class private FEntries: TArray; + FFolders: TArray; FUsername: string; FExportedAt: string; public @@ -47,12 +56,22 @@ type /// Efface le contenu. Appele au verrouillage. 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. + /// /// 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 /// comparer allongerait inutilement son sejour en memoire de travail. - function Search(const AQuery: string; AIncludeTrash: Boolean = False): TArray; + /// 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; + + /// Dossiers declares par l'instantane, dans son ordre. + property Folders: TArray read FFolders; function Count: Integer; function LiveCount: Integer; @@ -66,7 +85,7 @@ type implementation uses - System.Character; + System.Character, System.Generics.Defaults; { TVaultEntry } @@ -88,6 +107,7 @@ end; procedure TVault.Clear; begin SetLength(FEntries, 0); + SetLength(FFolders, 0); FUsername := ''; FExportedAt := ''; end; @@ -155,6 +175,21 @@ begin FExportedAt := LRoot.GetValue('exported_at', LRoot.GetValue('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('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('name', ''); + if FFolders[N] <> '' then Inc(N); + end; + SetLength(FFolders, N); + end; + if not LRoot.TryGetValue('entries', LArr) then raise Exception.Create('Instantane sans tableau "entries"'); @@ -174,6 +209,9 @@ begin FEntries[N].Totp := LObj.GetValue('totp_secret', ''); FEntries[N].Tags := TagsToString(LObj.GetValue('tags')); FEntries[N].Fields := ParseFields(LObj.GetValue('custom_fields')); + FEntries[N].Template := LObj.GetValue('template', ''); + FEntries[N].IconB64 := LObj.GetValue('icon_b64', ''); + FEntries[N].UpdatedAt := LObj.GetValue('updated_at', ''); // deleted arrive en 0/1 depuis nos conteneurs ; absent = vivant. FEntries[N].Deleted := LObj.GetValue('deleted', 0) = 1; Inc(N); @@ -206,7 +244,8 @@ begin Result := FEntries[AIndex]; end; -function TVault.Search(const AQuery: string; AIncludeTrash: Boolean): TArray; +function TVault.Search(const AQuery: string; const AFolder: string; + AIncludeTrash: Boolean): TArray; var LQ: string; LRes: TList; @@ -230,12 +269,21 @@ begin begin E := FEntries[I]; 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); end; Result := LRes.ToArray; finally LRes.Free; 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(Result, TComparer.Construct( + function(const A, B: Integer): Integer + begin + Result := CompareText(FEntries[A].Display, FEntries[B].Display); + end)); end; end. diff --git a/android-client/README.md b/android-client/README.md index d5e00de..4a639ab 100644 --- a/android-client/README.md +++ b/android-client/README.md @@ -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 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 `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. @@ -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.Vault.pas` | l'instantané en mémoire : lecture et recherche | | `PMA.Totp.pas` | codes TOTP (RFC 6238), base32 ou `otpauth://` | +| `PMA.ScreenGuard.pas` | `FLAG_SECURE` (Release seulement) | | `selftest.dpr` | vérification console : conteneur + TOTP | | `PMA.TestVector.pas` | le vecteur embarqué, plus référencé par l'interface | | `make-testvector.js` | régénère le vecteur avec WebCrypto | diff --git a/mainApk.pas b/mainApk.pas index be1f73d..4828c65 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -24,7 +24,8 @@ interface uses 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.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Layouts, FMX.Edit, FMX.ListBox, @@ -91,8 +92,13 @@ type // --- ecran 2 : liste FLayList: TLayout; FSearch: TEdit; + FFolderBox: TComboBox; FList: TListBox; 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; // --- ecran 3 : detail // Les lignes sont construites PAR ENTREE : leur nombre depend des champs @@ -139,6 +145,8 @@ type procedure LockVault; procedure OnSearchChanged(Sender: TObject); + procedure PopulateFolders; + procedure RebuildIcons; procedure OnItemTap(const Sender: TCustomListBox; const AItem: TListBoxItem); procedure OnRevealTap(Sender: TObject); procedure OnCopyTap(Sender: TObject); @@ -164,7 +172,8 @@ var implementation 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} @@ -185,7 +194,9 @@ var begin inherited; Caption := 'Coffre'; + SecureScreen; // avant tout affichage de contenu FVault := TVault.Create; + FIcons := TObjectDictionary.Create([doOwnsValues]); BuildDetail; // cree en dernier a l'ecran = au-dessus des autres BuildList; @@ -220,6 +231,7 @@ end; destructor TForm8.Destroy; begin + FIcons.Free; FVault.Free; inherited; end; @@ -299,7 +311,7 @@ end; procedure TForm8.BuildList; var - LBar: TLayout; + LBar, LTop: TLayout; LBtnLock: TButton; begin FLayList := TLayout.Create(Self); @@ -335,10 +347,24 @@ begin FList.ItemHeight := 56; 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.Parent := FLayList; - FSearch.Align := TAlignLayout.Top; - FSearch.Margins.Rect := RectF(8, 8, 8, 8); + FSearch.Parent := LTop; + FSearch.Align := TAlignLayout.Client; FSearch.TextPrompt := 'Rechercher'; FSearch.OnChangeTracking := OnSearchChanged; end; @@ -523,6 +549,12 @@ begin LMeta := LMeta + FCurrent.Tags; end; 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(FRowShown, Length(FRows)); @@ -637,6 +669,7 @@ begin FETag := ''; FVault.Clear; FList.Clear; + FIcons.Clear; // Les lignes du detail portent des valeurs EN CLAIR (FRows) : les detruire // fait partie du verrouillage, pas de l'affichage. ClearDetailRows; @@ -649,6 +682,8 @@ begin FVault.LoadFromJson(APlainJson); Log(' ' + FVault.LiveCount.ToString + ' entrees, ' + FVault.TrashCount.ToString + ' en corbeille'); + RebuildIcons; + PopulateFolders; // Deja dans l'application : reconstruire la liste SANS y ramener // 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 @@ -909,16 +944,88 @@ end; 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); var I: Integer; LItem: TListBoxItem; E: TVaultEntry; + LFolder: string; + LBmp: TBitmap; begin // ponytail: refonte complete de la liste a chaque frappe. Sur un coffre de // quelques centaines d'entrees c'est imperceptible ; passer a un modele // 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; try @@ -931,13 +1038,16 @@ begin LItem.Text := E.Display; LItem.ItemData.Detail := E.Username; 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 end; finally FList.EndUpdate; end; - if FSearch.Text.Trim = '' then + if (FSearch.Text.Trim = '') and (LFolder = '') then FCount.Text := FVault.LiveCount.ToString + ' entrees' else FCount.Text := Length(FFiltered).ToString + ' / ' + diff --git a/pmserverApk.dpr b/pmserverApk.dpr index 324b5b0..6fc1358 100644 --- a/pmserverApk.dpr +++ b/pmserverApk.dpr @@ -10,7 +10,8 @@ uses PMA.Store in 'android-client\PMA.Store.pas', PMA.Biometric in 'android-client\PMA.Biometric.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}