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
+118 -8
View File
@@ -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<Integer, TBitmap>;
// --- 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<Integer, TBitmap>.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 + ' / ' +