@@ -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 += '
';
+ }
+ // 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