feat(android): attachments can be saved, section 3 item 1 is done

Two questions, and the code was only the second one.

WHERE THE FILE LANDS. The private directory is useless - no file manager
opens it and it leaves with the uninstall - and direct external storage has
been closed since Android 10. Of the three remaining doors, MediaStore ->
Downloads is the only one needing neither a permission nor an extra screen:
since API 29 an app may write what it inserted itself. The destination picker
would need an activity result, which FMX makes painful; sharing would need a
FileProvider in the manifest. Below API 29 it would take
WRITE_EXTERNAL_STORAGE - one of the ten permissions just removed - so it
refuses and says so.

WHERE THE CONTENT IS. TVault drops it on purpose: a single attachment took
the cache from 13 to 174 Ko. It is re-read from the raw snapshot, which has
been in memory ever since saving stopped fetching before publishing. The same
data now serves two needs that had nothing to do with each other.

Decoding and writing happen off the main thread, under the waiting veil: a
few megabytes would freeze the screen, and the veil would not even paint.

What leaves the application leaves its protection: the file lands in the
clear in Downloads. The confirmation names the place rather than saying
"saved".

Release configuration compiles too - checked, since that is what section 5
caught out.
This commit is contained in:
r-zakarya
2026-08-22 00:41:05 +01:00
parent cb2e2c7a5f
commit 6b3001666e
7 changed files with 319 additions and 6 deletions
+76 -1
View File
@@ -282,6 +282,8 @@ type
procedure DeleteEntriesAt(const AList: string);
/// Duplique des entrees. Liste vide = l'entree ouverte.
procedure DuplicateEntriesAt(const AList: string);
/// Ecrit la piece jointe de la ligne ARowIndex dans Telechargements.
procedure SaveAttachment(ARowIndex: Integer);
/// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot.
procedure ReconcileWithRemote;
procedure ReopenEntryByUuid(const AUuid: string);
@@ -307,7 +309,8 @@ implementation
uses
PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric, PMA.Totp,
PMA.ScreenGuard, PMA.Clipboard, PMA.Theme, PMA.WebUI, PMA.VaultPatch, System.JSON;
PMA.ScreenGuard, PMA.Clipboard, PMA.Theme, PMA.WebUI, PMA.VaultPatch,
PMA.SaveFile, System.JSON;
{$R *.fmx}
@@ -1239,6 +1242,75 @@ begin
end);
end;
/// ENREGISTRER UNE PIECE JOINTE - la derniere chose que le detail savait
/// montrer sans savoir la rendre.
///
/// Le contenu n'est PAS en memoire : TVault le jette a la lecture, et c'est
/// voulu. Il se relit dans l'instantane brut, pour cette entree seulement, au
/// moment ou on le demande.
///
/// Hors du thread principal : quelques mega-octets a decoder puis a ecrire
/// figeraient l'ecran, et le voile d'attente ne se peindrait meme pas.
///
/// CE QUI SORT DE L'APPLICATION SORT DE SA PROTECTION : le fichier atterrit en
/// clair dans Telechargements, lisible par tout ce qui les lit. C'est ce que
/// l'utilisateur demande en appuyant ; le taire serait pire que le dire.
procedure TForm8.SaveAttachment(ARowIndex: Integer);
var
LCaption, LName, LB64, LMime, LUuid: string;
LAttach: Integer;
begin
if FBusy then Exit;
if not DetailRowAt(FCurrent, ARowIndex, LCaption, LName, LAttach) then Exit;
if LAttach < 0 then Exit;
if (FPlain = '') or (FCurrent.Uuid = '') then
begin
WebCall('PM.toast("Open the vault again to save this file")');
Exit;
end;
LUuid := FCurrent.Uuid;
LMime := '';
if LAttach <= High(FCurrent.Attachments) then
LMime := FCurrent.Attachments[LAttach].Mime;
SetBusy(True);
TThread.CreateAnonymousThread(
procedure
var
LBytes: TBytes;
LSaved, LErr: string;
begin
LErr := '';
try
LB64 := AttachmentContent(FPlain, LUuid, LAttach);
if LB64 = '' then
LErr := 'this attachment has no content in the snapshot'
else
begin
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LB64);
LSaved := SaveToDownloads(LName, LMime, LBytes, LErr);
end;
except
on E: Exception do LErr := E.ClassName + ': ' + E.Message;
end;
TThread.Queue(nil,
procedure
begin
SetBusy(False);
if LSaved <> '' then
begin
Log('saved to Downloads: ' + LSaved);
WebCall('PM.toast(' + JsArg('Saved to Downloads: ' + LSaved) + ')');
end
else
begin
Log('save failed: ' + LErr);
WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')');
end;
end);
end).Start;
end;
procedure TForm8.DuplicateEntriesAt(const AList: string);
var
LUuids: TArray<string>;
@@ -1427,6 +1499,9 @@ begin
else if LAction = 'delentry' then
DeleteEntriesAt(TNetEncoding.URL.Decode(Param('i')))
else if LAction = 'savefile' then
SaveAttachment(StrToIntDef(Param('k'), -1))
else if LAction = 'dupentry' then
DuplicateEntriesAt(TNetEncoding.URL.Decode(Param('i')))