diff --git a/AndroidManifest.template.xml b/AndroidManifest.template.xml index 82e3abe..60563dd 100644 --- a/AndroidManifest.template.xml +++ b/AndroidManifest.template.xml @@ -34,6 +34,13 @@ + + + <%queries-child-elements%> diff --git a/android-client/web/app.html b/android-client/web/app.html index 9a2e983..52f962d 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -585,6 +585,7 @@

+
@@ -1067,6 +1068,7 @@ function renderDetail(d) { detailRows = d.rows || []; document.getElementById('dtitle').textContent = d.title || ''; document.getElementById('kebab').hidden = false; + document.getElementById('pencil').hidden = false; var html = '', i; if (d.totp) html += '
2FA code
' @@ -1126,6 +1128,8 @@ document.getElementById('fields').addEventListener('click', function (ev) { send('touch'); return; } + var ia = t.getAttribute('data-iconact'); + if (ia !== null) { send('icon?a=' + ia); return; } var save = t.getAttribute('data-save'); if (save !== null) { send('savefile?k=' + save); return; } var copy = t.getAttribute('data-copy'); @@ -1458,6 +1462,7 @@ function openNewEntry() { // qui, cote Delphi, visent la derniere entree ouverte. closeMenu(); document.getElementById('kebab').hidden = true; + document.getElementById('pencil').hidden = true; document.getElementById('fields').innerHTML = '
' + '
Kind
' @@ -1565,13 +1570,83 @@ function closeMenu() { function openMenu() { var m = document.getElementById('menu'); m.innerHTML = '' - + '' - + '' + '' + ''; m.hidden = false; } +// MODIFIER L'ENTREE ENTIERE, PAS UN CHAMP A LA FOIS. +// +// Chaque ligne avait son crayon : ouvrir, taper, valider, recommencer - et +// CHAQUE validation etait un enregistrement complet, donc un commit de plus +// dans le depot. Ici un seul formulaire, un seul Save, un seul commit. +// +// La page ne connait toujours pas le schema : elle rend une ligne par champ +// que Delphi a marque modifiable ('e' porte son nom), dans l'ordre qu'il a +// choisi. Ajouter un champ modifiable cote Delphi suffit a le voir ici. +function openEditEntry() { + if (!lastDetail) return; + closeMenu(); + var html = '
', i, r; + for (i = 0; i < detailRows.length; i++) { + r = detailRows[i]; + if (!r.e) continue; + html += '
' + esc(r.c) + '
' + + (r.m + ? '' + : '') + + (r.e === 'password' ? genButton('editpwd') : '') + + '
'; + } + // L'ICONE SE CHANGE OU SE CHANGENT LES CHAMPS. Elle vivait dans le menu de + // l'entree, a cote de Supprimer : deux actions de nature opposee au meme + // endroit, et rien qui montre l'icone du moment. + html += '
Icon
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
'; + document.getElementById('fields').innerHTML = html; + document.getElementById('eCancel').addEventListener('click', function () { + renderDetail(lastDetail); + }); + document.getElementById('eSave').addEventListener('click', saveEditEntry); + send('touch'); +} + +/// UN SEUL ENVOI, ET SEULEMENT CE QUI A CHANGE : le reste du coffre n'a pas a +/// etre reecrit parce qu'on a corrige une faute de frappe dans un nom. +function saveEditEntry() { + var ins = document.querySelectorAll('#fields [data-ef]'), q = [], n = 0, i, el, f, v, k; + for (i = 0; i < ins.length; i++) { + el = ins[i]; + f = el.getAttribute('data-ef'); + v = el.value; + for (k = 0; k < detailRows.length; k++) + if (detailRows[k].e === f && detailRows[k].v === v) { f = ''; break; } + if (!f) continue; + q.push('f' + n + '=' + encodeURIComponent(f) + '&v' + n + '=' + encodeURIComponent(v)); + n++; + } + if (!n) { renderDetail(lastDetail); return; } + document.getElementById('eSave').disabled = true; + send('setfields?' + q.join('&')); +} + +document.getElementById('pencil').innerHTML = ico('edit', 'Edit entry'); +document.getElementById('pencil').addEventListener('click', openEditEntry); document.getElementById('kebab').innerHTML = ico('dots', 'Entry menu'); document.getElementById('kebab').addEventListener('click', function (ev) { ev.stopPropagation(); diff --git a/mainApk.pas b/mainApk.pas index d0a1ab2..e84491c 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -48,6 +48,7 @@ uses FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Layouts, FMX.Edit, FMX.ListBox, FMX.Objects, FMX.WebBrowser, System.Messaging, FMX.Media, + FMX.MediaLibrary, FMX.MediaLibrary.Actions, PMA.Vault; const @@ -340,6 +341,9 @@ type /// Change UN champ de l'entree ouverte. Le nom du champ vient de la page, /// et n'est donc pas cru sur parole. procedure SaveEntryField(const AField, ANewValue: string); + /// Plusieurs champs d'un coup, en UN SEUL enregistrement - donc un seul + /// commit dans le depot. Les deux tableaux vont par paires. + procedure SaveEntryFields(const AFields, AValues: TArray); /// Ajoute une entree au coffre. Le mot de passe voyage par le pont, /// comme 'setfield' - meme regle, meme journalisation muette. procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind, @@ -382,6 +386,9 @@ type // Scanner du QR affiche par le poste (ZXing.Delphi, Apache 2.0). var FScanning, FScanBusy: Boolean; + /// L'action de la galerie survit a l'appel : le selecteur d'Android rend + /// la main tout de suite et rappelle plus tard. + FPickIcon: TTakePhotoFromLibraryAction; FScanFrame: Integer; FScanLay: TLayout; FScanImg: TImage; @@ -401,6 +408,12 @@ type function PwdName: string; /// Va chercher l'icone du site de l'entree ouverte, ou l'efface. procedure SetEntryIcon(AFetch: Boolean); + /// CHOISIR UNE IMAGE DE LA GALERIE comme icone. Elle est reduite avant + /// d'entrer dans le coffre - une photo de telephone pese quelques Mo, et + /// une icone se regarde a 32 px dans une liste. + procedure PickEntryIcon; + procedure BeginPickIcon; + procedure IconPicked(AImage: TBitmap); /// Le depot a bouge, ou ne l'a plus : LE DRAPEAU ET SON TEMOIN ENSEMBLE. /// /// Ils ne l'etaient pas. FRemoteAhead etait remis a faux a trois endroits - @@ -1254,6 +1267,46 @@ begin end); end; +/// L'EDITION DE L'ENTREE ENTIERE. Un champ a la fois, c'etait un commit par +/// champ : corriger un nom et son adresse laissait deux instantanes dans le +/// depot. Ici les corrections s'appliquent l'une apres l'autre au meme clair, +/// et c'est le resultat qui part. +/// +/// Le filtre des noms est celui de SaveEntryField, et pour la meme raison : ce +/// qui traverse le pont se traite comme une entree exterieure. +procedure TForm8.SaveEntryFields(const AFields, AValues: TArray); +var + LUuid, LTitle: string; + I: Integer; +begin + if Length(AFields) = 0 then Exit; + if FCurrent.Uuid = '' then + begin + RefuseChange('This entry has no uuid - cannot be updated'); + Exit; + end; + for I := 0 to High(AFields) do + if (AFields[I] <> 'title') and (AFields[I] <> 'site') + and (AFields[I] <> 'username') and (AFields[I] <> 'password') + and (AFields[I] <> 'folder') and (AFields[I] <> 'totp_secret') then + begin + RefuseChange('That field cannot be changed from here'); + Exit; + end; + LUuid := FCurrent.Uuid; + LTitle := FCurrent.Display; + CommitChange('Saved: ' + LTitle, + Format('%d field(s) changed from phone', [Length(AFields)]), LUuid, + function (APlain: string): string + var + J: Integer; + begin + Result := APlain; + for J := 0 to High(AFields) do + Result := PatchEntryField(Result, LUuid, AFields[J], AValues[J]); + end); +end; + /// SUPPRIMER - la seule action sans retour depuis le telephone. /// /// Elle ne met pas a la corbeille : celle du poste ne voyage pas dans la @@ -1950,7 +2003,10 @@ begin end else if LAction = 'icon' then - SetEntryIcon(Param('a') = 'fetch') + begin + if Param('a') = 'pick' then PickEntryIcon + else SetEntryIcon(Param('a') = 'fetch'); + end {$IFDEF DEBUG} // JETER LE COFFRE LOCAL - debogage seulement, et absent a la compilation @@ -2002,6 +2058,21 @@ begin SaveEntryField(TNetEncoding.URL.Decode(Param('f')), TNetEncoding.URL.Decode(Param('v'))) + // f0=&v0=&f1=&v1=... : autant de paires que de champs modifies. + else if LAction = 'setfields' then + begin + var LF: TArray := []; + var LV: TArray := []; + var LN := 0; + while Param('f' + IntToStr(LN)) <> '' do + begin + LF := LF + [TNetEncoding.URL.Decode(Param('f' + IntToStr(LN)))]; + LV := LV + [TNetEncoding.URL.Decode(Param('v' + IntToStr(LN)))]; + Inc(LN); + end; + SaveEntryFields(LF, LV); + end + // La page vient de poser le focus sur la recherche : on deplie le clavier. else if LAction = 'kbd' then ShowSoftKeyboard @@ -3392,6 +3463,100 @@ begin end).Start; end; +/// L'ICONE VENUE DE LA GALERIE. Provenance a VRAI, comme un televersement +/// depuis le poste : une icone choisie ne doit jamais etre remplacee par une +/// recherche automatique, et ce drapeau est ce qui le dit au poste. +const + ICON_MAX_PX = 128; + +procedure TForm8.IconPicked(AImage: TBitmap); +var + LSmall: TBitmap; + LStream: TMemoryStream; + LB64: TBase64Encoding; + LUri, LTitle, LUuid: string; + LScale: Single; +begin + if (AImage = nil) or (AImage.Width = 0) or (AImage.Height = 0) then Exit; + if FCurrent.Uuid = '' then Exit; + LUuid := FCurrent.Uuid; + LTitle := FCurrent.Display; + if AImage.Width > AImage.Height then + LScale := ICON_MAX_PX / AImage.Width + else + LScale := ICON_MAX_PX / AImage.Height; + if LScale > 1 then LScale := 1; + LSmall := TBitmap.Create(Round(AImage.Width * LScale), + Round(AImage.Height * LScale)); + try + LSmall.Canvas.BeginScene; + try + LSmall.Canvas.DrawBitmap(AImage, RectF(0, 0, AImage.Width, AImage.Height), + RectF(0, 0, LSmall.Width, LSmall.Height), 1, True); + finally + LSmall.Canvas.EndScene; + end; + LStream := TMemoryStream.Create; + try + LSmall.SaveToStream(LStream); // PNG + // Sans coupure de ligne : une URI data: coupee ne s'affiche pas. + LB64 := TBase64Encoding.Create(0); + try + LUri := 'data:image/png;base64,' + + LB64.EncodeBytesToString(LStream.Memory, LStream.Size); + finally + LB64.Free; + end; + finally + LStream.Free; + end; + finally + LSmall.Free; + end; + Log(Format('icon: picked image, %d chars', [Length(LUri)])); + CommitChange('Icon set: ' + LTitle, 'icon picked on phone', LUuid, + function (APlain: string): string + begin + Result := PatchEntryIcon(APlain, LUuid, LUri, True); + end); +end; + +procedure TForm8.BeginPickIcon; +begin + if FPickIcon = nil then + begin + FPickIcon := TTakePhotoFromLibraryAction.Create(Self); + FPickIcon.OnDidFinishTaking := IconPicked; + end; + FPickIcon.Execute; +end; + +procedure TForm8.PickEntryIcon; +begin + if FBusy then Exit; + if FCurrent.Uuid = '' then + begin + RefuseChange('This entry has no uuid'); + Exit; + end; + {$IFDEF ANDROID} + // Les deux noms : le premier est celui d'Android 13 et plus, le second celui + // d'avant. Un nom inconnu du systeme est simplement refuse, et le selecteur + // recent n'exige de toute facon rien - on ouvre donc dans tous les cas, et + // c'est Android qui refuse s'il doit refuser. + PermissionsService.RequestPermissions( + ['android.permission.READ_MEDIA_IMAGES', + 'android.permission.READ_EXTERNAL_STORAGE'], + procedure(const APermissions: TClassicStringDynArray; + const AGrantResults: TClassicPermissionStatusDynArray) + begin + BeginPickIcon; + end); + {$ELSE} + BeginPickIcon; + {$ENDIF} +end; + /// POSER OU CHANGER LE CODE, IMMEDIATEMENT ET SUR DEMANDE. /// /// Le coffre doit etre ouvert : c'est la seule preuve que le code courant est