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.
1. **Pièces jointes** — elles sont dans l'instantané, personne ne les lit.
Lister nom/taille par entrée, et enregistrer. **Décision à prendre avant
d'écrire quoi que ce soit** : où enregistrer sur Android 10+. Le répertoire
privé de l'application ne sert à rien à l'utilisateur, et *Téléchargements*
passe par `MediaStore` ou un `Intent` de partage. C'est cette question, pas
le code, qui bloque. Attention aussi à la mémoire : `content_b64` est déjà
décodé en RAM avec le reste de l'instantané, un coffre avec des PDF pèse.
1. ~~**Pièces jointes**~~ — **abandonné** (2026-08-15) : le coffre n'en contient
pas. Elles restent dans l'instantané et le client les ignore, ce qui ne
coûte rien. À reprendre le jour où il y en aura, en tranchant d'abord où un
fichier atterrit sur Android 10+ — le répertoire privé ne sert à rien à
l'utilisateur, *Téléchargements* passe par `MediaStore` ou un partage par
`Intent` — et en surveillant la mémoire, `content_b64` étant décodé en RAM
avec le reste de l'instantané.
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
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
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,
FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs, FMX.Platform,
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.
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
// une fois le conteneur telecharge. Absent apres un clone -> recopier
// PMA.LocalConfig.template.inc.
@@ -121,6 +126,12 @@ type
FClipTimer: TTimer;
FCopied: string;
// --- verrouillage sur inactivite
FIdleTimer: TTimer;
FLastActivity: TDateTime;
procedure Touch;
procedure OnIdleTimer(Sender: TObject);
procedure BuildUnlock;
procedure BuildList;
procedure BuildDetail;
@@ -176,7 +187,7 @@ implementation
uses
PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric, PMA.Totp,
PMA.ScreenGuard;
PMA.ScreenGuard, PMA.Clipboard;
{$R *.fmx}
@@ -215,6 +226,13 @@ begin
FTotpTimer.Interval := 1000;
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
// il ferme l'application depuis le detail, au lieu de revenir a la liste.
OnKeyUp := OnHardwareBack;
@@ -532,6 +550,7 @@ var
end;
begin
Touch;
ClearDetailRows;
FCurrent := FVault.Entry(AVaultIndex);
FDetTitle.Text := FCurrent.Display;
@@ -673,6 +692,7 @@ begin
// afficher - et sur un poste sans cache local (hors Android), plus aucun
// moyen de rouvrir le coffre sans relancer l'application.
FETag := '';
FIdleTimer.Enabled := False;
FVault.Clear;
FList.Clear;
FIcons.Clear;
@@ -690,6 +710,8 @@ begin
FVault.TrashCount.ToString + ' en corbeille');
RebuildIcons;
PopulateFolders;
Touch;
FIdleTimer.Enabled := True;
// 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
@@ -1060,6 +1082,7 @@ 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.
Touch;
LFolder := '';
if FFolderBox.ItemIndex > 0 then // 0 = tous les dossiers
LFolder := FFolderBox.Items[FFolderBox.ItemIndex];
@@ -1105,6 +1128,7 @@ procedure TForm8.OnRevealTap(Sender: TObject);
var
I: Integer;
begin
Touch;
if not (Sender is TButton) then Exit;
I := TButton(Sender).Tag;
if (I < 0) or (I > High(FRows)) then Exit;
@@ -1126,6 +1150,7 @@ procedure TForm8.OnCopyTap(Sender: TObject);
var
I: Integer;
begin
Touch;
if not (Sender is TButton) then Exit;
I := TButton(Sender).Tag;
// -1 : la ligne du TOTP, qui n'est pas dans FRows - sa valeur change chaque
@@ -1137,13 +1162,12 @@ begin
end;
procedure TForm8.CopyToClipboard(const AText, ALabel: string);
var
LClip: IFMXClipboardService;
begin
if AText = '' then Exit;
if not TPlatformServices.Current.SupportsPlatformService(
IFMXClipboardService, LClip) then Exit;
LClip.SetClipboard(AText);
// Marque le contenu sensible : sans cela Android 13+ affiche un apercu de ce
// qui vient d'etre copie, mot de passe compris, en clair et hors de
// l'application.
if not CopySensitive(AText) then Exit;
FCopied := AText;
// Reprogramme depuis zero : deux copies successives donnent au second
// contenu son delai complet, pas le reste de celui du premier.
@@ -1157,17 +1181,12 @@ begin
end;
procedure TForm8.OnClipTimer(Sender: TObject);
var
LClip: IFMXClipboardService;
begin
FClipTimer.Enabled := False;
if FCopied = '' then Exit;
if TPlatformServices.Current.SupportsPlatformService(
IFMXClipboardService, LClip) then
// Seulement si c'est TOUJOURS notre texte : l'utilisateur a pu copier
// autre chose entre-temps, et le lui effacer serait une surprise.
if LClip.GetClipboard.ToString = FCopied then
LClip.SetClipboard('');
// Seulement si c'est TOUJOURS notre texte : l'utilisateur a pu copier autre
// chose entre-temps, et le lui effacer serait une surprise.
if CurrentClipboard = FCopied then ClearClipboard;
FCopied := '';
end;
@@ -1211,6 +1230,26 @@ begin
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);
begin
FStatus.Text := AMsg;
+2 -1
View File
@@ -11,7 +11,8 @@ uses
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.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}