From 7939d268f371bf8ef259db9ee4ba8b0ffbd3c489 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:58:27 +0100 Subject: [PATCH] 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 --- android-client/PLAN.md | 14 ++-- android-client/PMA.Clipboard.pas | 136 +++++++++++++++++++++++++++++++ mainApk.pas | 69 ++++++++++++---- pmserverApk.dpr | 3 +- 4 files changed, 199 insertions(+), 23 deletions(-) create mode 100644 android-client/PMA.Clipboard.pas diff --git a/android-client/PLAN.md b/android-client/PLAN.md index 115c75d..b2ad7d7 100644 --- a/android-client/PLAN.md +++ b/android-client/PLAN.md @@ -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 diff --git a/android-client/PMA.Clipboard.pas b/android-client/PMA.Clipboard.pas new file mode 100644 index 0000000..94ba175 --- /dev/null +++ b/android-client/PMA.Clipboard.pas @@ -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. diff --git a/mainApk.pas b/mainApk.pas index 80e801d..0cd83f2 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -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; diff --git a/pmserverApk.dpr b/pmserverApk.dpr index 6fc1358..f0ddbda 100644 --- a/pmserverApk.dpr +++ b/pmserverApk.dpr @@ -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}