From 1dba5d392f59a3ed591b7796b2ed3f84499ced9d Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:11:14 +0100 Subject: [PATCH] fix(android): the tap lands on the drawing, not on the button Copying a password or a username opened the entry instead - about half the time, which is the worst kind of failure to diagnose. Since those buttons carry an icon, the tap target is the or one of its strokes, so reading the attribute off the target returned null and the handler fell through to "open". A finger landing sometimes on the glyph and sometimes on the margin made it look random. Both handlers now resolve to the button first. The new-entry form gets a folder, like the editor: an entry filed from the start saves having to move it. And a folder can now be created, not only chosen. Filing an entry under a name is not enough - it is `folders` that fills the desktop's column, and a name missing from it leaves the entry in a folder nothing displays, looking lost. The two now go together, in one place, for both editing and creation. 'All' is never registered: it is the default, not a folder. --- android-client/PMA.VaultPatch.pas | 40 ++++++++++++++++++++-- android-client/web/app.html | 56 ++++++++++++++++++++++++++++--- mainApk.pas | 11 +++--- 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas index 6620687..9fcc049 100644 --- a/android-client/PMA.VaultPatch.pas +++ b/android-client/PMA.VaultPatch.pas @@ -49,7 +49,7 @@ function NewEmptySnapshot(const AUsername: string): string; /// /// Un uuid neuf, et lui seul : pas de tombstone, on ne supprime pas encore. function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, - AKind: string): string; + AKind, AFolder: string): string; /// Retire une entree de l'instantane et laisse une PIERRE TOMBALE a sa place. /// @@ -142,6 +142,37 @@ end; /// Copie profonde d'un objet JSON : le reparser est le moyen le plus court, et /// le seul qui n'oublie aucun champ. +/// Inscrit un dossier dans la liste du coffre s'il n'y figure pas deja. +/// +/// Ranger une entree dans un dossier ne suffit pas : c'est `folders` qui peuple +/// la colonne du poste, et un nom qui n'y figure pas donne une entree rangee +/// dans un dossier que rien n'affiche - elle semble disparue. Les deux vont +/// donc ensemble, et 'All' ne s'inscrit jamais : c'est le defaut, pas un +/// dossier. +procedure EnsureFolder(const ARoot: TJSONObject; const AName: string); +var + LFolders: TJSONArray; + LObj: TJSONObject; + I: Integer; +begin + if (AName = '') or (AName = 'All') then Exit; + if not ARoot.TryGetValue('folders', LFolders) then + begin + LFolders := TJSONArray.Create; + ARoot.AddPair('folders', LFolders); + end; + for I := 0 to LFolders.Count - 1 do + if (LFolders.Items[I] is TJSONObject) and + ((LFolders.Items[I] as TJSONObject).GetValue('name', '') = AName) then + Exit; + LObj := TJSONObject.Create; + LObj.AddPair('name', AName); + // Le poste lit aussi 'color' et 'icon' ; vides, il met ses defauts. + LObj.AddPair('color', ''); + LObj.AddPair('icon', ''); + LFolders.AddElement(LObj); +end; + function CloneJson(const AObj: TJSONObject): TJSONObject; begin Result := TJSONObject.ParseJSONValue(AObj.ToJSON) as TJSONObject; @@ -279,7 +310,7 @@ begin end; function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, - AKind: string): string; + AKind, AFolder: string): string; var LRoot, LEntry: TJSONObject; LEntries: TJSONArray; @@ -303,7 +334,9 @@ begin // Le corps d'une note EST son mot de passe cote stockage - il n'y a pas de // colonne separee, ni ici ni sur le poste. LEntry.AddPair('password', APassword); - LEntry.AddPair('folder', 'All'); + if AFolder <> '' then LEntry.AddPair('folder', AFolder) + else LEntry.AddPair('folder', 'All'); + EnsureFolder(LRoot, AFolder); LEntry.AddPair('tags', TJSONArray.Create); LEntry.AddPair('totp_secret', ''); LEntry.AddPair('kind', AKind); @@ -356,6 +389,7 @@ begin // reste plus ancien que le leur. LEntry.RemovePair('updated_at').Free; LEntry.AddPair('updated_at', NowUtcIso); + if AField = 'folder' then EnsureFolder(LRoot, AValue); LFound := True; Break; diff --git a/android-client/web/app.html b/android-client/web/app.html index b2e5d6a..97cf720 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -562,8 +562,14 @@ function renderRows() { document.getElementById('rows').addEventListener('click', function (ev) { var row = ev.target.closest('.row'); if (!row) return; - var copy = ev.target.getAttribute('data-copy'); - if (copy) send('copy?i=' + row.getAttribute('data-i') + '&f=' + copy); + // REMONTER AU BOUTON. Depuis que ces boutons portent un dessin, la cible de + // l'appui est le ou l'un de ses traits, pas le bouton : lire + // l'attribut sur la cible rendait null, et l'appui ouvrait l'entree au lieu + // de copier. Le doigt tombant tantot sur le glyphe tantot sur la marge, cela + // ne ratait qu'une fois sur deux - la pire des pannes a diagnostiquer. + var btn = ev.target.closest('[data-copy]'); + if (btn) send('copy?i=' + row.getAttribute('data-i') + + '&f=' + btn.getAttribute('data-copy')); else send('open?i=' + row.getAttribute('data-i')); }); @@ -677,7 +683,10 @@ function renderDetail(d) { } document.getElementById('fields').addEventListener('click', function (ev) { - var t = ev.target, k = t.getAttribute('data-show'); + // Meme raison que dans la liste : le dessin recoit l'appui, le bouton porte + // l'intention. Tout ce qui suit lit et ecrit sur le bouton. + var t = ev.target.closest('button') || ev.target; + var k = t.getAttribute('data-show'); if (t.id === 'del') { if (t.getAttribute('data-armed') === null) { t.setAttribute('data-armed', '1'); @@ -778,9 +787,31 @@ function editorControl(k) { html += ''; + // CHOISIR NE SUFFIT PAS : le premier dossier d'un coffre n'existe pas encore, + // et rien ne permettait de le nommer. Cette option echange la liste contre un + // champ libre, sans quitter l'editeur. + html += ''; return html + ''; } +var NEW_FOLDER = '\u0001new'; + +// Le choix "+ new folder" remplace la liste par un champ. Delegue sur #fields +// parce que le controle nait et meurt avec l'editeur. +document.getElementById('fields').addEventListener('change', function (ev) { + if (ev.target.id !== 'editval' || ev.target.value !== NEW_FOLDER) return; + var line = ev.target.parentNode; + var input = document.createElement('input'); + input.className = 'field-in'; + input.id = 'editval'; + input.setAttribute('placeholder', 'folder name'); + input.setAttribute('autocomplete', 'off'); + input.setAttribute('autocapitalize', 'none'); + input.style.cssText = 'margin:0;flex:1;min-width:0;width:auto'; + line.replaceChild(input, ev.target); + input.focus(); +}); + // EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au // moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors // null sans un mot - le bouton semblerait ne rien faire. @@ -895,6 +926,20 @@ addEventListener('keydown', function (ev) { // place a l'ecran, et c'est exactement ce que cette vue veut dire. var newKind = 'login'; +/// Le dossier a la creation. Le meme choix que dans l'editeur, et pour la +/// meme raison : une entree rangee des le depart evite d'avoir a la deplacer. +function newFolderField() { + var names = ['All'], i; + for (i = 0; i < folders.length; i++) + if (folders[i] && names.indexOf(folders[i]) < 0) names.push(folders[i]); + var html = '
Folder
' + + '
'; +} + function newField(id, label, type) { return '
' + label + '
' + '
Kind
' + '' + '
' + + newFolderField() + newField('nTitle', 'Name', 'text') + newField('nSite', 'Site', 'text') + newField('nUser', 'Username', 'text') @@ -953,7 +999,9 @@ document.getElementById('fields').addEventListener('click', function (ev) { if (ev.target.id !== 'nSave') return; var v = function (id) { return document.getElementById(id).value.trim(); }; ev.target.disabled = true; - send('newentry?t=' + encodeURIComponent(v('nTitle')) + var fold = document.getElementById('nFolder'); + send('newentry?d=' + encodeURIComponent(fold ? fold.value : 'All') + + '&t=' + encodeURIComponent(v('nTitle')) + '&s=' + encodeURIComponent(v('nSite')) + '&u=' + encodeURIComponent(v('nUser')) + '&p=' + encodeURIComponent(document.getElementById('nPwd').value) diff --git a/mainApk.pas b/mainApk.pas index f6acf92..f513ae2 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -274,7 +274,8 @@ type procedure SaveEntryField(const AField, ANewValue: string); /// 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: string); + procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind, + AFolder: string); /// Supprime l'entree ouverte, pierre tombale comprise. Sans retour. procedure DeleteCurrentEntry; /// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot. @@ -1192,7 +1193,8 @@ begin end); end; -procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); +procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind, + AFolder: string); begin // Une entree sans rien pour la reconnaitre serait une ligne muette dans la // liste, et rien ne permettrait de la retrouver pour la corriger. @@ -1204,7 +1206,7 @@ begin CommitChange('Added: ' + ATitle, 'new entry from phone', '', function (APlain: string): string begin - Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind); + Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind, AFolder); end); end; @@ -1351,7 +1353,8 @@ begin TNetEncoding.URL.Decode(Param('s')), TNetEncoding.URL.Decode(Param('u')), TNetEncoding.URL.Decode(Param('p')), - TNetEncoding.URL.Decode(Param('k'))) + TNetEncoding.URL.Decode(Param('k')), + TNetEncoding.URL.Decode(Param('d'))) else if LAction = 'delentry' then DeleteCurrentEntry