feat(android): mark the clipboard sensitive, and lock after 3 min idle

Since Android 13 the system shows a preview of whatever was just copied, at the bottom of the screen. For a password that is precisely what we are trying to avoid: it appears in clear, outside the app, for several seconds. The flag that suppresses it is a ClipDescription extra, which IFMXClipboardService does not expose - hence going straight to ClipboardManager. Note Jcontent_ClipboardManager, not JClipboardManager: the latter is the deprecated android.text one, which knows only bare text and has no ClipData to carry an extra.

Backgrounding already locks; the idle timer covers the phone left face-up on a table with the app open. It samples timestamps every 15 s rather than counting seconds - a wake-up per second would cost battery for no precision that matters.

Attachments dropped from the plan: this vault has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-15 14:58:27 +01:00
parent 0e39cf7e56
commit 7939d268f3
4 changed files with 199 additions and 23 deletions
+7 -7
View File
@@ -171,13 +171,13 @@ 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**~~ — **abandonné** (2026-08-15) : le coffre n'en contient
Lister nom/taille par entrée, et enregistrer. **Décision à prendre avant pas. Elles restent dans l'instantané et le client les ignore, ce qui ne
d'écrire quoi que ce soit** : où enregistrer sur Android 10+. Le répertoire coûte rien. À reprendre le jour où il y en aura, en tranchant d'abord où un
privé de l'application ne sert à rien à l'utilisateur, et *Téléchargements* fichier atterrit sur Android 10+ — le répertoire privé ne sert à rien à
passe par `MediaStore` ou un `Intent` de partage. C'est cette question, pas l'utilisateur, *Téléchargements* passe par `MediaStore` ou un partage par
le code, qui bloque. Attention aussi à la mémoire : `content_b64` est déjà `Intent` — et en surveillant la mémoire, `content_b64` étant décodé en RAM
décodé en RAM avec le reste de l'instantané, un coffre avec des PDF pèse. avec le reste de l'instantané.
2. ~~**Filtre dossier**~~ — **fait**, liste déroulante à côté de la recherche, 2. ~~**Filtre dossier**~~ — **fait**, liste déroulante à côté de la recherche,
alimentée par le tableau `folders`, et le choix survit à un rafraîchissement alimentée par le tableau `folders`, et le choix survit à un rafraîchissement
de fond. **Pas de filtre d'étiquette** : la recherche texte couvre déjà les de fond. **Pas de filtre d'étiquette** : la recherche texte couvre déjà les
+136
View File
@@ -0,0 +1,136 @@
unit PMA.Clipboard;
{
Copie dans le presse-papiers, en marquant le contenu SENSIBLE.
POURQUOI PAS IFMXClipboardService : depuis Android 13, le systeme affiche un
apercu de ce qui vient d'etre copie, en bas de l'ecran. Pour un mot de passe,
c'est exactement ce qu'on cherche a eviter - il s'affiche en clair, hors de
l'application, et reste visible quelques secondes.
Le drapeau qui supprime cet apercu est un extra du ClipDescription, que le
service FMX n'expose pas. D'ou ce passage direct par ClipboardManager.
Cela ne remplace pas l'effacement differe : le drapeau cache l'apercu, il ne
vide pas le presse-papiers.
}
interface
/// Copie ATexte. Retourne False si la plateforme n'a rien fourni - l'appelant
/// peut alors retomber sur le service FMX.
function CopySensitive(const AText: string): Boolean;
/// Contenu courant, ou chaine vide. Sert a ne vider le presse-papiers que
/// s'il contient toujours ce qu'on y a mis.
function CurrentClipboard: string;
/// Vide le presse-papiers.
procedure ClearClipboard;
implementation
{$IF DEFINED(ANDROID)}
uses
System.SysUtils, Androidapi.Helpers, Androidapi.JNI.JavaTypes,
Androidapi.JNI.GraphicsContentViewText, Androidapi.JNI.Os, Androidapi.JNIBridge;
const
// android.content.ClipDescription.EXTRA_IS_SENSITIVE, ecrit en dur : la
// constante n'existe qu'a partir de l'API 33 dans les en-tetes, alors que la
// valeur est reconnue par le systeme des qu'il sait la lire, et ignoree
// ailleurs. Un extra inconnu ne derange personne.
EXTRA_IS_SENSITIVE = 'android.content.extra.IS_SENSITIVE';
/// Jcontent_ClipboardManager et NON JClipboardManager : ce dernier est
/// l'android.text.ClipboardManager deprecie, qui ne connait que du texte nu et
/// aucun ClipData - donc aucun extra ou poser le drapeau.
function Manager: Jcontent_ClipboardManager;
var
LObj: JObject;
begin
LObj := TAndroidHelper.Context.getSystemService(
TJContext.JavaClass.CLIPBOARD_SERVICE);
if LObj = nil then Exit(nil);
Result := TJcontent_ClipboardManager.Wrap((LObj as ILocalObject).GetObjectID);
end;
function CopySensitive(const AText: string): Boolean;
var
LMgr: Jcontent_ClipboardManager;
LClip: JClipData;
LExtras: JPersistableBundle;
begin
Result := False;
try
LMgr := Manager;
if LMgr = nil then Exit;
// L'etiquette est visible du systeme : elle ne doit rien decrire du
// contenu, seulement son origine.
// Les DEUX arguments sont des CharSequence : passer un JString au premier
// compile ailleurs mais pas ici.
LClip := TJClipData.JavaClass.newPlainText(StrToJCharSequence('coffre'),
StrToJCharSequence(AText));
LExtras := TJPersistableBundle.JavaClass.init;
LExtras.putBoolean(StringToJString(EXTRA_IS_SENSITIVE), True);
LClip.getDescription.setExtras(LExtras);
LMgr.setPrimaryClip(LClip);
Result := True;
except
Result := False; // l'appelant retombera sur le service FMX
end;
end;
function CurrentClipboard: string;
var
LMgr: Jcontent_ClipboardManager;
LClip: JClipData;
begin
Result := '';
try
LMgr := Manager;
if (LMgr = nil) or not LMgr.hasPrimaryClip then Exit;
LClip := LMgr.getPrimaryClip;
if (LClip = nil) or (LClip.getItemCount < 1) then Exit;
Result := JCharSequenceToStr(LClip.getItemAt(0).getText);
except
Result := '';
end;
end;
procedure ClearClipboard;
begin
// Un contenu vide plutot qu'un appel a clearPrimaryClip, absent avant l'API
// 28 : le resultat visible est le meme.
CopySensitive('');
end;
{$ELSE}
uses
System.SysUtils, System.Rtti, FMX.Platform;
function CopySensitive(const AText: string): Boolean;
var
LClip: IFMXClipboardService;
begin
// Hors Android il n'y a pas d'apercu systeme a supprimer : le service
// standard suffit.
Result := TPlatformServices.Current.SupportsPlatformService(
IFMXClipboardService, LClip);
if Result then LClip.SetClipboard(AText);
end;
function CurrentClipboard: string;
var
LClip: IFMXClipboardService;
begin
Result := '';
if TPlatformServices.Current.SupportsPlatformService(IFMXClipboardService, LClip) then
Result := LClip.GetClipboard.ToString;
end;
procedure ClearClipboard;
begin
CopySensitive('');
end;
{$ENDIF}
end.
+54 -15
View File
@@ -24,7 +24,7 @@ interface
uses uses
System.SysUtils, System.Types, System.UITypes, System.Classes, System.SysUtils, System.Types, System.UITypes, System.Classes,
System.Diagnostics, System.Rtti, System.NetEncoding, System.Diagnostics, System.Rtti, System.NetEncoding, System.DateUtils,
System.Generics.Collections, 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,
@@ -51,6 +51,11 @@ const
// mot de passe traine dans l'historique du presse-papiers. // mot de passe traine dans l'historique du presse-papiers.
CLIPBOARD_CLEAR_SECONDS = 45; CLIPBOARD_CLEAR_SECONDS = 45;
// Verrouillage sur inactivite au premier plan. Le passage en arriere-plan
// verrouille deja ; ceci couvre le telephone pose sur une table, ecran
// allume, application ouverte.
AUTOLOCK_MINUTES = 3;
// TEST_PWD vient d'un fichier LOCAL, hors depot : il ouvre le coffre entier // TEST_PWD vient d'un fichier LOCAL, hors depot : il ouvre le coffre entier
// une fois le conteneur telecharge. Absent apres un clone -> recopier // une fois le conteneur telecharge. Absent apres un clone -> recopier
// PMA.LocalConfig.template.inc. // PMA.LocalConfig.template.inc.
@@ -121,6 +126,12 @@ type
FClipTimer: TTimer; FClipTimer: TTimer;
FCopied: string; FCopied: string;
// --- verrouillage sur inactivite
FIdleTimer: TTimer;
FLastActivity: TDateTime;
procedure Touch;
procedure OnIdleTimer(Sender: TObject);
procedure BuildUnlock; procedure BuildUnlock;
procedure BuildList; procedure BuildList;
procedure BuildDetail; procedure BuildDetail;
@@ -176,7 +187,7 @@ 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; PMA.ScreenGuard, PMA.Clipboard;
{$R *.fmx} {$R *.fmx}
@@ -215,6 +226,13 @@ begin
FTotpTimer.Interval := 1000; FTotpTimer.Interval := 1000;
FTotpTimer.OnTimer := OnTotpTick; FTotpTimer.OnTimer := OnTotpTick;
// Tick grossier : on compare des horodatages, il ne sert qu'a echantillonner.
// Une seconde de precision n'apporterait rien et reveillerait l'appareil.
FIdleTimer := TTimer.Create(Self);
FIdleTimer.Enabled := False;
FIdleTimer.Interval := 15000;
FIdleTimer.OnTimer := OnIdleTimer;
// Le bouton retour materiel arrive comme une touche : sans ce gestionnaire // Le bouton retour materiel arrive comme une touche : sans ce gestionnaire
// il ferme l'application depuis le detail, au lieu de revenir a la liste. // il ferme l'application depuis le detail, au lieu de revenir a la liste.
OnKeyUp := OnHardwareBack; OnKeyUp := OnHardwareBack;
@@ -532,6 +550,7 @@ var
end; end;
begin begin
Touch;
ClearDetailRows; ClearDetailRows;
FCurrent := FVault.Entry(AVaultIndex); FCurrent := FVault.Entry(AVaultIndex);
FDetTitle.Text := FCurrent.Display; FDetTitle.Text := FCurrent.Display;
@@ -673,6 +692,7 @@ begin
// afficher - et sur un poste sans cache local (hors Android), plus aucun // afficher - et sur un poste sans cache local (hors Android), plus aucun
// moyen de rouvrir le coffre sans relancer l'application. // moyen de rouvrir le coffre sans relancer l'application.
FETag := ''; FETag := '';
FIdleTimer.Enabled := False;
FVault.Clear; FVault.Clear;
FList.Clear; FList.Clear;
FIcons.Clear; FIcons.Clear;
@@ -690,6 +710,8 @@ begin
FVault.TrashCount.ToString + ' en corbeille'); FVault.TrashCount.ToString + ' en corbeille');
RebuildIcons; RebuildIcons;
PopulateFolders; PopulateFolders;
Touch;
FIdleTimer.Enabled := True;
// 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
@@ -1060,6 +1082,7 @@ 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.
Touch;
LFolder := ''; LFolder := '';
if FFolderBox.ItemIndex > 0 then // 0 = tous les dossiers if FFolderBox.ItemIndex > 0 then // 0 = tous les dossiers
LFolder := FFolderBox.Items[FFolderBox.ItemIndex]; LFolder := FFolderBox.Items[FFolderBox.ItemIndex];
@@ -1105,6 +1128,7 @@ procedure TForm8.OnRevealTap(Sender: TObject);
var var
I: Integer; I: Integer;
begin begin
Touch;
if not (Sender is TButton) then Exit; if not (Sender is TButton) then Exit;
I := TButton(Sender).Tag; I := TButton(Sender).Tag;
if (I < 0) or (I > High(FRows)) then Exit; if (I < 0) or (I > High(FRows)) then Exit;
@@ -1126,6 +1150,7 @@ procedure TForm8.OnCopyTap(Sender: TObject);
var var
I: Integer; I: Integer;
begin begin
Touch;
if not (Sender is TButton) then Exit; if not (Sender is TButton) then Exit;
I := TButton(Sender).Tag; I := TButton(Sender).Tag;
// -1 : la ligne du TOTP, qui n'est pas dans FRows - sa valeur change chaque // -1 : la ligne du TOTP, qui n'est pas dans FRows - sa valeur change chaque
@@ -1137,13 +1162,12 @@ begin
end; end;
procedure TForm8.CopyToClipboard(const AText, ALabel: string); procedure TForm8.CopyToClipboard(const AText, ALabel: string);
var
LClip: IFMXClipboardService;
begin begin
if AText = '' then Exit; if AText = '' then Exit;
if not TPlatformServices.Current.SupportsPlatformService( // Marque le contenu sensible : sans cela Android 13+ affiche un apercu de ce
IFMXClipboardService, LClip) then Exit; // qui vient d'etre copie, mot de passe compris, en clair et hors de
LClip.SetClipboard(AText); // l'application.
if not CopySensitive(AText) then Exit;
FCopied := AText; FCopied := AText;
// Reprogramme depuis zero : deux copies successives donnent au second // Reprogramme depuis zero : deux copies successives donnent au second
// contenu son delai complet, pas le reste de celui du premier. // contenu son delai complet, pas le reste de celui du premier.
@@ -1157,17 +1181,12 @@ begin
end; end;
procedure TForm8.OnClipTimer(Sender: TObject); procedure TForm8.OnClipTimer(Sender: TObject);
var
LClip: IFMXClipboardService;
begin begin
FClipTimer.Enabled := False; FClipTimer.Enabled := False;
if FCopied = '' then Exit; if FCopied = '' then Exit;
if TPlatformServices.Current.SupportsPlatformService( // Seulement si c'est TOUJOURS notre texte : l'utilisateur a pu copier autre
IFMXClipboardService, LClip) then // chose entre-temps, et le lui effacer serait une surprise.
// Seulement si c'est TOUJOURS notre texte : l'utilisateur a pu copier if CurrentClipboard = FCopied then ClearClipboard;
// autre chose entre-temps, et le lui effacer serait une surprise.
if LClip.GetClipboard.ToString = FCopied then
LClip.SetClipboard('');
FCopied := ''; FCopied := '';
end; end;
@@ -1211,6 +1230,26 @@ begin
end); end);
end; end;
procedure TForm8.Touch;
begin
FLastActivity := Now;
end;
procedure TForm8.OnIdleTimer(Sender: TObject);
begin
// Rien d'ouvert : rien a verrouiller.
if FVault.Count = 0 then
begin
FIdleTimer.Enabled := False;
Exit;
end;
if MinutesBetween(Now, FLastActivity) >= AUTOLOCK_MINUTES then
begin
Log('Verrouille apres ' + AUTOLOCK_MINUTES.ToString + ' min d''inactivite');
LockVault;
end;
end;
procedure TForm8.Status(const AMsg: string); procedure TForm8.Status(const AMsg: string);
begin begin
FStatus.Text := AMsg; FStatus.Text := AMsg;
+2 -1
View File
@@ -11,7 +11,8 @@ uses
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'; PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas',
PMA.Clipboard in 'android-client\PMA.Clipboard.pas';
{$R *.res} {$R *.res}