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