From 6b3001666efc0224e35f8ef7b9f5dac5f5b14241 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:41:05 +0100 Subject: [PATCH] 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. --- android-client/PLAN.md | 30 ++++++- android-client/PMA.SaveFile.pas | 136 ++++++++++++++++++++++++++++++ android-client/PMA.VaultPatch.pas | 44 ++++++++++ android-client/PMA.WebUI.pas | 27 +++++- android-client/web/app.html | 8 ++ mainApk.pas | 77 ++++++++++++++++- pmserverApk.dpr | 3 +- 7 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 android-client/PMA.SaveFile.pas diff --git a/android-client/PLAN.md b/android-client/PLAN.md index 90c8641..366c8a7 100644 --- a/android-client/PLAN.md +++ b/android-client/PLAN.md @@ -271,7 +271,35 @@ Par ordre de gravité, pas de taille. Rangé par ce que ça apporte réellement sur un téléphone. -1. **Pièces jointes** — *listées*, pas encore enregistrables. +1. ~~**Pièces jointes**~~ — **enregistrables depuis le 2026-08-22.** + + Deux questions, et le code n'était que la seconde. + + **Où le fichier atterrit.** Le répertoire privé ne sert à rien — aucun + gestionnaire de fichiers ne l'ouvre, et il part avec la désinstallation. Le + stockage externe direct est fermé depuis Android 10. Restaient trois portes : + `MediaStore` → *Téléchargements*, le sélecteur de destination + (`ACTION_CREATE_DOCUMENT`), le partage (`ACTION_SEND`). **MediaStore**, parce + que c'est la seule qui ne demande **ni permission ni écran supplémentaire** : + depuis l'API 29, une application peut écrire ce qu'elle a elle-même inséré. + Le sélecteur exigerait un retour d'activité, que FMX rend pénible ; le + partage, un `FileProvider` au manifeste. + En dessous de l'API 29 il faudrait `WRITE_EXTERNAL_STORAGE` — précisément + l'une des dix permissions retirées en §5. On refuse et on le dit. + + **Où retrouver le contenu.** `TVault` le jette à la lecture, et c'est voulu : + une seule pièce jointe faisait passer le cache de 13 à 174 Ko. Il se relit + donc dans **l'instantané brut**, qui est en mémoire depuis que + l'enregistrement ne tire plus avant de publier — la même donnée sert + maintenant deux besoins qui n'avaient rien à voir. + + Décodage et écriture hors du thread principal, sous le voile d'attente : + quelques mégaoctets figeraient l'écran, et le voile ne se peindrait même pas. + + **Ce qui sort de l'application sort de sa protection** : le fichier atterrit + en clair dans *Téléchargements*, lisible par tout ce qui les lit. C'est ce + que l'utilisateur demande en appuyant, et le message de confirmation nomme + l'endroit plutôt que de dire « enregistré ». Une seule pièce jointe a fait passer le cache de **13 à 174 Ko** : le contenu voyage en base64 dans l'instantané, et il est déchiffré en mémoire diff --git a/android-client/PMA.SaveFile.pas b/android-client/PMA.SaveFile.pas new file mode 100644 index 0000000..d1ab2da --- /dev/null +++ b/android-client/PMA.SaveFile.pas @@ -0,0 +1,136 @@ +unit PMA.SaveFile; +{ + Deposer un fichier la ou l'utilisateur le retrouvera. + + LA QUESTION N'ETAIT PAS LE CODE, C'ETAIT L'ENDROIT. Le repertoire prive de + l'application ne sert a rien : aucun gestionnaire de fichiers ne l'ouvre, et + il part avec la desinstallation. Le stockage externe direct est ferme depuis + Android 10. Restent trois portes, et une seule ne demande ni permission ni + ecran supplementaire : + + - MediaStore -> Telechargements : le systeme cree l'entree, nous ecrivons + dedans, le fichier apparait dans "Telechargements" avec les autres. AUCUNE + permission depuis l'API 29, parce que l'application n'ecrit que ce qu'elle + a elle-meme insere. + - Le selecteur de destination (ACTION_CREATE_DOCUMENT) donnerait le choix, + mais il exige un retour d'activite, que FMX rend penible. + - Le partage (ACTION_SEND) exigerait un FileProvider declare au manifeste. + + Donc MediaStore. En dessous de l'API 29 il faudrait WRITE_EXTERNAL_STORAGE, + une permission qu'on vient justement de retirer des dix defauts de Delphi : + on refuse et on le dit, plutot que de la reprendre pour des versions + d'Android que personne ici n'utilise. + + CE QUI SORT DE L'APPLICATION SORT DE SA PROTECTION. Le fichier depose est + en clair, lisible par tout ce qui lit les telechargements. C'est ce que + l'utilisateur demande en appuyant sur "enregistrer", et c'est pour cela que + l'appel le dit dans son nom plutot que de s'appeler "export". +} + +interface + +uses + System.SysUtils; + +/// Ecrit ADonnees dans Telechargements sous AFileName. +/// Rend le nom retenu par le systeme, ou '' en cas d'echec - AError dit alors +/// pourquoi. +function SaveToDownloads(const AFileName, AMime: string; const AData: TBytes; + out AError: string): string; + +implementation + +{$IF DEFINED(ANDROID)} +// UNE SEULE CLAUSE uses PAR SECTION : deux d'affilee, meme separees par une +// directive, sont une erreur de syntaxe et non un ajout. +uses + Androidapi.Helpers, Androidapi.JNI.JavaTypes, Androidapi.JNI.Os, + Androidapi.JNI.GraphicsContentViewText, Androidapi.JNI.Provider, + // Androidapi.JNI.Net pour Jnet_Uri : le resolveur rend une URI, pas un + // chemin - c'est elle qui designe la ligne que le systeme vient de creer. + Androidapi.JNI.Net, Androidapi.JNIBridge; + +const + // MediaStore.MediaColumns, ecrites en dur comme EXTRA_IS_SENSITIVE ailleurs : + // les noms de colonnes sont stables depuis toujours, et les chercher dans les + // en-tetes coute plus que de les lire ici. + COL_NAME = '_display_name'; + COL_MIME = 'mime_type'; + COL_PATH = 'relative_path'; + +function SaveToDownloads(const AFileName, AMime: string; const AData: TBytes; + out AError: string): string; +var + LValues: JContentValues; + LUri: Jnet_Uri; + LOut: JOutputStream; + LArr: TJavaArray; + LMime: string; +begin + Result := ''; + AError := ''; + if Length(AData) = 0 then + begin + AError := 'nothing to write'; + Exit; + end; + if TJBuild_VERSION.JavaClass.SDK_INT < 29 then + begin + AError := 'saving files needs Android 10 or later'; + Exit; + end; + + LMime := AMime; + // Un type vide ferait ranger le fichier sans que rien ne sache l'ouvrir. + if LMime = '' then LMime := 'application/octet-stream'; + + try + LValues := TJContentValues.JavaClass.init; + LValues.put(StringToJString(COL_NAME), StringToJString(AFileName)); + LValues.put(StringToJString(COL_MIME), StringToJString(LMime)); + // Le systeme se charge des doublons : deux fichiers du meme nom deviennent + // "nom" et "nom (1)". Rien a gerer de notre cote. + LValues.put(StringToJString(COL_PATH), StringToJString('Download')); + + LUri := TAndroidHelper.Context.getContentResolver.insert( + TJMediaStore_Downloads.JavaClass.EXTERNAL_CONTENT_URI, LValues); + if LUri = nil then + begin + AError := 'the system refused to create the file'; + Exit; + end; + + LOut := TAndroidHelper.Context.getContentResolver.openOutputStream(LUri); + if LOut = nil then + begin + AError := 'the system gave no way to write'; + Exit; + end; + LArr := TJavaArray.Create(Length(AData)); + try + Move(AData[0], LArr.Data^, Length(AData)); + LOut.write(LArr); + LOut.flush; + finally + LOut.close; + LArr.Free; + end; + Result := AFileName; + except + on E: Exception do + begin + AError := E.ClassName + ': ' + E.Message; + Result := ''; + end; + end; +end; +{$ELSE} +function SaveToDownloads(const AFileName, AMime: string; const AData: TBytes; + out AError: string): string; +begin + Result := ''; + AError := 'Downloads are Android-only'; +end; +{$ENDIF} + +end. diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas index 55d4514..c5bc99e 100644 --- a/android-client/PMA.VaultPatch.pas +++ b/android-client/PMA.VaultPatch.pas @@ -73,6 +73,20 @@ function DeleteEntry(const APlainJson, AUuid: string): string; /// lourd a publier ; il se voit tout de suite, lui. function DuplicateEntry(const APlainJson, AUuid: string): string; +/// Le contenu base64 d'une piece jointe, relu dans l'instantane BRUT. +/// +/// TVault le jette expres a la lecture - une seule piece jointe faisait passer +/// le cache de 13 a 174 Ko, et le garder en memoire pour tout le coffre alors +/// que rien ne l'affiche serait payer sans rien acheter. Il ne se relit donc +/// qu'ici, au moment ou quelqu'un demande a l'enregistrer, et pour une seule +/// entree. +/// +/// Rend '' si l'entree, le rang ou le contenu manquent - une piece jointe +/// listee sans contenu existe : le poste peut avoir echoue a le joindre a +/// l'instantane. +function AttachmentContent(const APlainJson, AUuid: string; + AIndex: Integer): string; + /// Fond l'instantane LOCAL dans l'instantane DISTANT et rend le resultat. /// /// LE DISTANT SERT DE BASE, jamais le local. Il porte ce que le telephone ne @@ -266,6 +280,36 @@ begin end; end; +function AttachmentContent(const APlainJson, AUuid: string; + AIndex: Integer): string; +var + LRoot, LEntry: TJSONObject; + LEntries, LAtt: TJSONArray; + I: Integer; +begin + Result := ''; + if (AUuid = '') or (AIndex < 0) then Exit; + LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject; + if LRoot = nil then Exit; + try + if not LRoot.TryGetValue('entries', LEntries) then Exit; + for I := 0 to LEntries.Count - 1 do + begin + if not (LEntries.Items[I] is TJSONObject) then Continue; + LEntry := LEntries.Items[I] as TJSONObject; + if LEntry.GetValue('uuid', '') <> AUuid then Continue; + if not LEntry.TryGetValue('attachments', LAtt) then Exit; + if AIndex >= LAtt.Count then Exit; + if not (LAtt.Items[AIndex] is TJSONObject) then Exit; + Result := (LAtt.Items[AIndex] as TJSONObject) + .GetValue('content_b64', ''); + Exit; + end; + finally + LRoot.Free; + end; +end; + function DuplicateEntry(const APlainJson, AUuid: string): string; var LRoot, LEntry, LCopy: TJSONObject; diff --git a/android-client/PMA.WebUI.pas b/android-client/PMA.WebUI.pas index 00ff67a..206fc52 100644 --- a/android-client/PMA.WebUI.pas +++ b/android-client/PMA.WebUI.pas @@ -41,7 +41,10 @@ function DetailToJson(const AEntry: TVaultEntry): string; /// page renvoie un rang, jamais une valeur, et Delphi relit le clair chez lui. /// Faux si le rang ne designe rien. function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer; - out ACaption, AValue: string): Boolean; + out ACaption, AValue: string): Boolean; overload; +/// Variante qui rend aussi le rang de la piece jointe, ou -1. +function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer; + out ACaption, AValue: string; out AAttachIndex: Integer): Boolean; overload; implementation @@ -160,6 +163,9 @@ type /// Supprimer une entree n'y figure pas : cela demande des tombstones, et /// c'est l'etape 3 de la §7. Field: string; + /// Rang de la piece jointe dans l'entree, ou -1. Il ne se deduit pas du + /// rang de la ligne : les champs personnalises s'intercalent avant. + AttachIndex: Integer; end; function BuildRows(const E: TVaultEntry): TArray; @@ -168,7 +174,7 @@ var I: Integer; procedure Add(const ACaption, AValue: string; ASecret: Boolean; - const AField: string = ''); + const AField: string = ''; AAttach: Integer = -1); begin // Une ligne vide n'apprend rien - SAUF si elle se remplit. Un identifiant // absent qu'on ne peut pas ajouter serait une impasse. @@ -178,6 +184,7 @@ var LRows[High(LRows)].Value := AValue; LRows[High(LRows)].Secret := ASecret; LRows[High(LRows)].Field := AField; + LRows[High(LRows)].AttachIndex := AAttach; end; begin @@ -210,7 +217,7 @@ begin // entree porte un document, et pas ici. for I := 0 to High(E.Attachments) do Add('Attachment - ' + HumanSize(E.Attachments[I].SizeBytes), - E.Attachments[I].FileName, False); + E.Attachments[I].FileName, False, '', I); // DOSSIER ET ETIQUETTES SEPARES, parce qu'ils ne se modifient pas pareil. // Le dossier est une chaine simple, choisie parmi celles qui existent - la @@ -252,6 +259,10 @@ begin LObj.AddPair('s', TJSONBool.Create(LRows[I].Secret)); // 'e' porte le nom du champ, et sa presence vaut "modifiable". if LRows[I].Field <> '' then LObj.AddPair('e', LRows[I].Field); + // 'a' porte le rang de la piece jointe : sa presence vaut + // "enregistrable", et la page y met le bouton de telechargement. + if LRows[I].AttachIndex >= 0 then + LObj.AddPair('a', TJSONNumber.Create(LRows[I].AttachIndex)); LArr.AddElement(LObj); end; Result := LRoot.ToJSON; @@ -262,14 +273,24 @@ end; function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer; out ACaption, AValue: string): Boolean; +var + LAttach: Integer; +begin + Result := DetailRowAt(AEntry, AIndex, ACaption, AValue, LAttach); +end; + +function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer; + out ACaption, AValue: string; out AAttachIndex: Integer): Boolean; var LRows: TArray; begin + AAttachIndex := -1; LRows := BuildRows(AEntry); Result := (AIndex >= 0) and (AIndex <= High(LRows)); if not Result then Exit; ACaption := LRows[AIndex].Caption; AValue := LRows[AIndex].Value; + AAttachIndex := LRows[AIndex].AttachIndex; end; end. diff --git a/android-client/web/app.html b/android-client/web/app.html index ad1bf0b..6a56f12 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -687,6 +687,7 @@ var ICONS = { edit: '', eye: '' + '', + down: '', trash: '' + '', dots: '' @@ -741,6 +742,11 @@ function renderDetail(d) { + ico('eye', 'Show') + '' : '') + '' + // 'a' porte le rang de la piece jointe : sa presence dit qu'il y a + // quelque chose a enregistrer, et Delphi seul sait ou. + + (r.a === undefined ? '' + : '') + (r.e ? '' : '') + ''; @@ -767,6 +773,8 @@ document.getElementById('fields').addEventListener('click', function (ev) { send('touch'); return; } + var save = t.getAttribute('data-save'); + if (save !== null) { send('savefile?k=' + save); return; } var copy = t.getAttribute('data-copy'); if (copy === 'totp') { send('copy?f=totp'); return; } if (copy === 'row') { send('copy?f=row&k=' + t.getAttribute('data-k')); return; } diff --git a/mainApk.pas b/mainApk.pas index 39fd119..482bfb5 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -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; @@ -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'))) diff --git a/pmserverApk.dpr b/pmserverApk.dpr index 963482f..418f2f1 100644 --- a/pmserverApk.dpr +++ b/pmserverApk.dpr @@ -15,7 +15,8 @@ uses PMA.Clipboard in 'android-client\PMA.Clipboard.pas', PMA.Theme in 'android-client\PMA.Theme.pas', PMA.WebUI in 'android-client\PMA.WebUI.pas', - PMA.VaultPatch in 'android-client\PMA.VaultPatch.pas'; + PMA.VaultPatch in 'android-client\PMA.VaultPatch.pas', + PMA.SaveFile in 'android-client\PMA.SaveFile.pas'; {$R *.res}