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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user