Files
Password-Manager/android-client/PMA.Clipboard.pas
T
r-zakarya 7939d268f3 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>
2026-08-15 14:58:27 +01:00

137 lines
4.2 KiB
ObjectPascal

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.