feat(android): an entry can be deleted from the phone

Both, or nothing: the row leaves the snapshot AND a tombstone takes its
place. Removing the row alone does not delete - the desktop still holds its
own copy, and the next pull would bring it back as an entry we never had.
The time matters too: the desktop's arbitration keeps an entry edited AFTER
the deletion rather than killing it a second time.

No trash on the phone, deliberately. The desktop's is a `deleted` flag that
does not travel in sync, so a deletion stopping there would propagate
nowhere. Hence a button at the very bottom, in two taps, that disarms itself
after four seconds - a button left armed gets touched by accident much
later, and this is the one action with no way back.

The confirmation is in the page because the engine cannot offer one: a
JavaScript prompt needs a WebChromeClient FMX does not provide.

Not exercised on the device: it would have destroyed a real entry.
This commit is contained in:
r-zakarya
2026-08-21 16:21:45 +01:00
parent 7a0ae6398b
commit 5107ebf7b2
4 changed files with 140 additions and 1 deletions
+13 -1
View File
@@ -670,7 +670,19 @@ téléphone qu'on voudra ensuite ouvrir sur le poste demandera un import.
Une ligne vide s'affiche désormais **si elle est éditable** : un identifiant Une ligne vide s'affiche désormais **si elle est éditable** : un identifiant
absent qu'on ne pourrait pas ajouter serait une impasse. Vider est permis, absent qu'on ne pourrait pas ajouter serait une impasse. Vider est permis,
sauf sur un mot de passe où ce serait presque toujours une fausse manoeuvre. sauf sur un mot de passe où ce serait presque toujours une fausse manoeuvre.
**Reste : supprimer**, qui exige les tombstones. **Supprimer** — fait le 2026-08-21. `DeleteEntry` retire la ligne **et** pose
la pierre tombale `{uuid, deleted_at}` : les deux, sinon rien. Retirer la
ligne seule ne supprime pas — le poste tient toujours la sienne, et le
prochain tirage la ramènerait comme une entrée qu'on n'a jamais eue. Et
l'heure compte : l'arbitrage du poste garde une entrée modifiée *après* la
suppression plutôt que de la tuer une seconde fois.
**Sans retour depuis le téléphone**, et c'est délibéré : la corbeille du
poste est un drapeau `deleted` qui ne voyage pas dans la synchronisation, donc
une suppression qui s'y arrêterait ne se propagerait nulle part. D'où le
bouton en bas d'écran, en deux temps, qui se désarme seul au bout de quatre
secondes — un bouton armé indéfiniment se fait toucher par mégarde bien plus
tard.
4. **Réconcilier avec la synchronisation** : un coffre né seul auquel on 4. **Réconcilier avec la synchronisation** : un coffre né seul auquel on
ajoute un dépôt. C'est là que la fusion revient, et elle a été évitée ajoute un dépôt. C'est là que la fusion revient, et elle a été évitée
jusqu'ici pour de bonnes raisons. jusqu'ici pour de bonnes raisons.
+65
View File
@@ -51,6 +51,20 @@ function NewEmptySnapshot(const AUsername: string): string;
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
AKind: string): string; AKind: string): string;
/// Retire une entree de l'instantane et laisse une PIERRE TOMBALE a sa place.
///
/// LES DEUX, SINON RIEN. Retirer la ligne sans la tombstone ne supprime pas :
/// le poste tient toujours la sienne, et le prochain tirage la ramenerait comme
/// une entree qu'on n'a jamais eue. C'est la tombstone qui dit "celle-ci a ete
/// supprimee, et a telle heure" - et l'heure compte, parce que l'arbitrage du
/// poste garde une entree modifiee APRES la suppression plutot que de la tuer
/// une seconde fois.
///
/// SANS RETOUR depuis le telephone : il n'y a pas de corbeille ici. Celle du
/// poste est un drapeau `deleted` qui ne voyage pas dans la synchronisation ;
/// une suppression qui s'y arreterait ne se propagerait donc nulle part.
function DeleteEntry(const APlainJson, AUuid: string): string;
/// Horodatage UTC au format que tout le reste emploie (toISOString cote JS). /// Horodatage UTC au format que tout le reste emploie (toISOString cote JS).
/// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une /// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une
/// fois : il n'y a qu'une source d'heure ici, et elle est en UTC. /// fois : il n'y a qu'une source d'heure ici, et elle est en UTC.
@@ -86,6 +100,57 @@ begin
end; end;
end; end;
function DeleteEntry(const APlainJson, AUuid: string): string;
var
LRoot, LEntry, LStone: TJSONObject;
LEntries, LStones: TJSONArray;
I: Integer;
LFound: Boolean;
begin
if AUuid = '' then
raise EPMAPatch.Create('Entree sans uuid : rien a supprimer chez le distant');
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Instantane illisible');
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
raise EPMAPatch.Create('Instantane sans tableau "entries"');
LFound := False;
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<string>('uuid', '') <> AUuid then Continue;
// Remove REND la valeur retiree : a l'appelant de la liberer.
LEntries.Remove(I).Free;
LFound := True;
Break;
end;
if not LFound then
raise EPMAPatch.CreateFmt(
'Entree %s absente du distant - supprimee ailleurs ?', [AUuid]);
// Le tableau peut manquer : un conteneur ecrit par un poste qui n'avait
// encore rien supprime n'a aucune raison de le porter.
if not LRoot.TryGetValue<TJSONArray>('tombstones', LStones) then
begin
LStones := TJSONArray.Create;
LRoot.AddPair('tombstones', LStones);
end;
LStone := TJSONObject.Create;
LStone.AddPair('uuid', AUuid);
LStone.AddPair('deleted_at', NowUtcIso);
LStones.AddElement(LStone);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
AKind: string): string; AKind: string): string;
var var
+29
View File
@@ -154,6 +154,9 @@
.btn:active { opacity: .62; } .btn:active { opacity: .62; }
.btn.wide { padding: 13px 18px; font-size: 15px; } .btn.wide { padding: 13px 18px; font-size: 15px; }
.btn.main { background: var(--accent); } .btn.main { background: var(--accent); }
/* Rouge sourd : assez pour qu'on ne le confonde pas avec Copy, pas assez
pour attirer l'oeil sur la seule action irreversible de l'ecran. */
.btn.danger { background: rgba(220, 80, 80, .30); }
.empty { text-align: center; color: var(--muted); padding: 48px 16px; } .empty { text-align: center; color: var(--muted); padding: 48px 16px; }
@@ -593,6 +596,14 @@ function renderDetail(d) {
+ (r.e ? '<button class="btn" data-edit="' + i + '">Edit</button>' : '') + (r.e ? '<button class="btn" data-edit="' + i + '">Edit</button>' : '')
+ '</div></div>'; + '</div></div>';
} }
// SUPPRIMER, tout en bas et en deux temps. Sans retour possible depuis le
// telephone - il n'y a pas de corbeille ici - donc ni en tete d'ecran ni a
// portee d'un appui distrait. Le premier appui ne fait qu'armer ; c'est la
// confirmation, faute de pouvoir en demander une au moteur (une invite
// JavaScript exige un WebChromeClient que FMX ne fournit pas).
html += '<div class="field"><div class="line">'
+ '<button class="btn danger" id="del" style="flex:1">Delete entry'
+ '</button></div></div>';
document.getElementById('fields').innerHTML = html; document.getElementById('fields').innerHTML = html;
document.body.classList.add('on-detail'); document.body.classList.add('on-detail');
view = 'detail'; view = 'detail';
@@ -601,6 +612,24 @@ function renderDetail(d) {
document.getElementById('fields').addEventListener('click', function (ev) { document.getElementById('fields').addEventListener('click', function (ev) {
var t = ev.target, k = t.getAttribute('data-show'); var t = ev.target, k = t.getAttribute('data-show');
if (t.id === 'del') {
if (t.getAttribute('data-armed') === null) {
t.setAttribute('data-armed', '1');
t.textContent = 'Really delete - tap again';
// Desarme tout seul : un bouton reste arme indefiniment se fait toucher
// par megarde bien plus tard, et la suppression est sans retour.
setTimeout(function () {
if (!t.parentNode) return;
t.removeAttribute('data-armed');
t.textContent = 'Delete entry';
}, 4000);
return;
}
t.disabled = true;
t.textContent = 'Deleting...';
send('delentry');
return;
}
if (k !== null) { if (k !== null) {
var cell = document.querySelector('[data-v="' + k + '"]'); var cell = document.querySelector('[data-v="' + k + '"]');
var hidden = cell.classList.contains('mask'); var hidden = cell.classList.contains('mask');
+33
View File
@@ -242,6 +242,8 @@ type
/// Ajoute une entree au coffre. Le mot de passe voyage par le pont, /// Ajoute une entree au coffre. Le mot de passe voyage par le pont,
/// comme 'setfield' - meme regle, meme journalisation muette. /// comme 'setfield' - meme regle, meme journalisation muette.
procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string);
/// Supprime l'entree ouverte, pierre tombale comprise. Sans retour.
procedure DeleteCurrentEntry;
procedure ReopenEntryByUuid(const AUuid: string); procedure ReopenEntryByUuid(const AUuid: string);
procedure OnClipTimer(Sender: TObject); procedure OnClipTimer(Sender: TObject);
procedure OnDeadline(Sender: TObject); procedure OnDeadline(Sender: TObject);
@@ -901,6 +903,34 @@ begin
end); end);
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
/// synchronisation, donc s'y arreter ne supprimerait nulle part. Elle retire la
/// ligne ET pose la pierre tombale, ce qui fait disparaitre l'entree du poste
/// au prochain tirage.
///
/// La page demande confirmation avant d'en arriver ici.
procedure TForm8.DeleteCurrentEntry;
var
LUuid, LTitle: string;
begin
if FCurrent.Uuid = '' then
begin
RefuseChange('This entry has no uuid - cannot be deleted');
Exit;
end;
LUuid := FCurrent.Uuid;
LTitle := FCurrent.Display;
// Pas de rang a rouvrir : l'entree n'existera plus. AdoptVault ramene la
// liste, ce qui est exactement ou l'on veut se retrouver.
CommitChange('Deleted: ' + LTitle, 'entry deleted from phone', '',
function (APlain: string): string
begin
Result := DeleteEntry(APlain, LUuid);
end);
end;
procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string);
begin begin
// Une entree sans rien pour la reconnaitre serait une ligne muette dans la // Une entree sans rien pour la reconnaitre serait une ligne muette dans la
@@ -1061,6 +1091,9 @@ begin
TNetEncoding.URL.Decode(Param('p')), TNetEncoding.URL.Decode(Param('p')),
TNetEncoding.URL.Decode(Param('k'))) TNetEncoding.URL.Decode(Param('k')))
else if LAction = 'delentry' then
DeleteCurrentEntry
else if LAction = 'setfield' then else if LAction = 'setfield' then
SaveEntryField(TNetEncoding.URL.Decode(Param('f')), SaveEntryField(TNetEncoding.URL.Decode(Param('f')),
TNetEncoding.URL.Decode(Param('v'))) TNetEncoding.URL.Decode(Param('v')))