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}