feat(android): saving works without a connection

The save used to start from the network: without it, nothing was kept. But
what someone has just typed is already theirs - publishing is a consequence,
not a condition. The cache is written first, always, and the repository
follows when it can.

A failed publish no longer loses anything. It raises a flag, and the toast
says both things: that it is kept, and that the repository does not know yet.
A bare "failed" would have people retype what is already there.

On conflict the two snapshots are now MERGED rather than the change replayed.
Replaying one edit onto the fresh snapshot was right while writing required
being online; with offline saving several changes can be waiting here, and
replaying one would drop the others.

Sync publishes what is pending BEFORE fetching. Fetching first would overwrite
our cache with the repository's version and the offline change would vanish
without a word - exactly what offline saving promises to prevent.

A dot on the padlock says changes are waiting: discreet until looked for,
present at every glance.
This commit is contained in:
r-zakarya
2026-08-22 14:56:04 +01:00
parent 248cdafa49
commit a24b18e90f
2 changed files with 229 additions and 115 deletions
+14
View File
@@ -179,6 +179,14 @@
.btn.ico-btn svg { width: 22px; height: 22px; }
/* Le retour d'appui ne peut plus venir du fond : il vient du trait. */
.btn.ico-btn:active { opacity: .45; }
/* Le point des modifications en attente. Sur le cadenas parce qu'il est
toujours a l'ecran de la liste, et qu'il parle deja de l'etat du coffre. */
.ico.pending { position: relative; }
.ico.pending::after {
content: ''; position: absolute; top: 6px; right: 6px;
width: 9px; height: 9px; border-radius: 50%;
background: #ffb44d; box-shadow: 0 0 0 2px var(--bg-top);
}
@keyframes spin { to { transform: rotate(360deg); } }
/* L'ENREGISTREMENT DURE UNE A DEUX SECONDES, et le bouton doit le dire. Le
mot "Saving..." le disait ; une coche immobile ne dirait rien, et c'est
@@ -1459,6 +1467,12 @@ var PM = {
/// Delphi l'appelle des qu'il part travailler et des qu'il revient. Le
/// filet est son minuteur de 25 s : meme si une operation ne rend jamais la
/// main, le voile se leve.
/// DES MODIFICATIONS ATTENDENT ICI. Un point sur le cadenas : discret tant
/// qu'on ne le cherche pas, mais present a chaque coup d'oeil - c'est la
/// seule facon de savoir que le depot ignore encore ce qu'on vient de taper.
setPending: function (on) {
document.getElementById('lock').classList.toggle('pending', !!on);
},
setBusy: function (on) {
if (on) document.getElementById('busy').firstChild.innerHTML =
ico('loader', 'Working') + '<span>Please wait...</span>';
+215 -115
View File
@@ -148,6 +148,8 @@ type
/// Elle part au verrouillage, avec lui.
FKey, FKeySalt: TBytes;
FKeyIters: Integer;
/// Des modifications sont rangees ici sans avoir atteint le depot.
FPending: Boolean;
/// L'INSTANTANE BRUT, tel qu'il a ete recu. Pas une reserialisation de
/// TVault : celle-ci perdrait tout ce que le telephone ne lit pas -
/// le contenu des pieces jointes, l'avatar, les tombstones - c'est
@@ -286,6 +288,9 @@ type
procedure SaveAttachment(ARowIndex: Integer);
/// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot.
procedure ReconcileWithRemote;
/// Publie ce qui a ete enregistre hors connexion. Sans effet s'il n'y a
/// rien en attente.
procedure PublishPending;
procedure ReopenEntryByUuid(const AUuid: string);
procedure OnClipTimer(Sender: TObject);
procedure OnDeadline(Sender: TObject);
@@ -849,161 +854,144 @@ begin
LClient: TSyncClient;
LGet, LPut: TSyncResult;
LPlain, LNew, LBody, LErr: string;
LKey, LSalt: TBytes;
LIter, LAttempt: Integer;
LAttempt: Integer;
LSw: TStopwatch;
LDone: Boolean;
LPublished, LLocalOnly, LPatched: Boolean;
begin
LDone := False;
LErr := '';
LClient := nil;
LErr := '';
LNew := '';
LPublished := False;
LPatched := False;
LLocalOnly := HasLocalContainer;
try
try
if HasLocalContainer then
// ---------------------------------------------------------------
// 1. MODIFIER ET RANGER ICI. TOUJOURS, ET D'ABORD.
//
// L'enregistrement partait autrefois du reseau : sans lui, rien
// n'etait garde. Or ce que l'utilisateur vient de taper lui appartient
// deja - le publier est une consequence, pas une condition. On ecrit
// donc le cache avant toute chose, et le depot suit quand il peut.
// ---------------------------------------------------------------
LSw := TStopwatch.StartNew;
if LLocalOnly then LPlain := OpenContainer(LoadLocalContainer, FSyncPwd)
else LPlain := FPlain;
if LPlain = '' then
raise Exception.Create('no snapshot in hand - open the vault again');
LNew := APatch(LPlain);
LPatched := True;
Log(Format('save: patch in %d ms', [LSw.ElapsedMilliseconds]));
LSw := TStopwatch.StartNew;
LBody := EncryptContainerWithKey(LNew, FKey, FKeySalt, FKeyIters);
Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds]));
if LLocalOnly then SaveLocalContainer(LBody);
try
SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False,
FPinSealed, FKey, FKeySalt, FKeyIters);
except
on E: Exception do Log(' cache not written: ' + E.Message);
end;
// ---------------------------------------------------------------
// 2. PUBLIER SI ON PEUT. Un echec ici ne perd rien.
// ---------------------------------------------------------------
if LLocalOnly then
begin
// COFFRE LOCAL : rien a tirer, rien a publier, aucun conflit possible
// - il n'y a qu'un ecrivain. Le conteneur est reecrit d'abord, le
// cache ensuite : l'inverse laisserait un cache en avance sur
// l'autorite si l'ecriture echouait entre les deux.
LSw := TStopwatch.StartNew;
LPlain := OpenContainer(LoadLocalContainer, FSyncPwd);
LKey := FKey; LSalt := FKeySalt; LIter := FKeyIters;
LNew := APatch(LPlain);
Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds]));
LSw := TStopwatch.StartNew;
LBody := EncryptContainerWithKey(LNew, LKey, LSalt, LIter);
Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds]));
SaveLocalContainer(LBody);
try
SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False,
FPinSealed, FKey, FKeySalt, FKeyIters);
except
on E: Exception do Log(' cache not written: ' + E.Message);
end;
// Coffre ne ici : il n'y a pas de depot, donc rien a publier.
LPublished := True;
Log('save: written locally');
LDone := True;
end
else
begin
LClient := TSyncClient.Create(FUrl, FToken);
for LAttempt := 1 to 3 do
begin
// PREMIER ESSAI SANS TIRER. On tient l'instantane et le sha qui va
// avec ; le tirage ne servait qu'a s'assurer de modifier la
// derniere version publiee, et le sha le prouve mieux : s'il n'est
// plus le bon, Gitea refuse. On ne retire donc que lorsqu'on a
// vraiment quelque chose a apprendre.
if (LAttempt = 1) and (FPlain <> '') and (FETag <> '') then
begin
Log('save: publishing against the snapshot in hand');
LGet.Status := ssOK;
LGet.Container := '';
LGet.ETag := FETag;
LPlain := FPlain;
end
else
begin
Log(Format('save: attempt %d - GET', [LAttempt]));
// Sans empreinte connue : on VEUT le corps, pas un 304.
LGet := LClient.Fetch;
if LGet.Status <> ssOK then
begin
LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]);
Break;
end;
LPlain := '';
end;
try
// UNE SEULE DERIVATION PAR ENREGISTREMENT : la variante Ex rend
// la cle, son sel et les iterations, que le rechiffrement
// reutilise. Deriver deux fois le meme mot de passe coutait une
// seconde fois quatre a six secondes, pour une cle equivalente.
LSw := TStopwatch.StartNew;
// LPlain deja pose quand on publie contre l'instantane en main.
if LPlain = '' then
LPlain := OpenContainer(LGet.Container, FSyncPwd);
LKey := FKey; LSalt := FKeySalt; LIter := FKeyIters;
LNew := APatch(LPlain);
Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds]));
except
on E: Exception do
begin
LErr := E.Message;
Break;
end;
end;
LSw := TStopwatch.StartNew;
LBody := EncryptContainerWithKey(LNew, LKey, LSalt, LIter);
Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds]));
LSw := TStopwatch.StartNew;
LPut := LClient.Put(LBody, LGet.ETag, AComment);
LPut := LClient.Put(LBody, FETag, AComment);
Log(Format('save: PUT %d chars in %d ms',
[Length(LBody), LSw.ElapsedMilliseconds]));
if LPut.Status = ssOK then
begin
Log('save: published, new sha ' + LPut.ETag);
// Le cache local suit le distant, avec SON empreinte : les
// laisser diverger ferait retelecharger et redechiffrer a la
// prochaine ouverture pour retrouver ce qu'on vient d'ecrire.
FETag := LPut.ETag;
try
SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False,
FPinSealed, FKey, FKeySalt, FKeyIters);
except
on E: Exception do Log(' cache not written: ' + E.Message);
end;
LDone := True;
LPublished := True;
Log('save: published, new sha ' + LPut.ETag);
Break;
end;
if LPut.Status <> ssConflict then
begin
LErr := Format('HTTP %d %s', [LPut.HttpCode, LPut.Error]);
Break;
end;
if LPut.Status = ssConflict then
// LE DISTANT A BOUGE : ON FUSIONNE, on ne rejoue pas.
//
// Rejouer la seule modification en cours sur l'instantane frais
// etait juste tant qu'on ne pouvait ecrire qu'en ligne. Depuis
// qu'on enregistre hors connexion, plusieurs changements peuvent
// attendre ici : n'en rejouer qu'un perdrait les autres.
Log(Format('save: %d - another device published, merging',
[LPut.HttpCode]));
LGet := LClient.Fetch;
if LGet.Status <> ssOK then
begin
Log(Format('save: %d - another device published, re-reading',
[LPut.HttpCode]));
LPlain := '';
Continue;
LErr := Format('read failed (HTTP %d) %s',
[LGet.HttpCode, LGet.Error]);
Break;
end;
LNew := MergeSnapshots(LNew, OpenContainer(LGet.Container, FSyncPwd));
LBody := EncryptContainerWithKey(LNew, FKey, FKeySalt, FKeyIters);
FETag := LGet.ETag;
// Le cache suit la fusion : sans cela il resterait sur notre
// version d'avant, et le prochain essai repartirait de la.
try
SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False,
FPinSealed, FKey, FKeySalt, FKeyIters);
except
on E: Exception do Log(' cache not written: ' + E.Message);
end;
LErr := Format('write failed (HTTP %d) %s', [LPut.HttpCode, LPut.Error]);
Break;
end;
if (not LDone) and (LErr = '') then
if (not LPublished) and (LErr = '') then
LErr := 'the remote kept changing - try again';
end;
TThread.Queue(nil,
procedure
begin
if LDone then
begin
AdoptVault(LNew);
WebCall('PM.toast(' + JsArg(ALabel) + ')');
end
else
WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')');
end);
except
// FILET. Le rechiffrement et le PUT sont hors du try qui couvre le
// dechiffrement : une exception y sortait sans un mot, et l'echec
// devenait indiscernable d'une lenteur.
on E: Exception do
begin
LErr := E.ClassName + ': ' + E.Message;
Log('save FAILED: ' + LErr);
TThread.Queue(nil, procedure
begin WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')'); end);
end;
end;
TThread.Queue(nil,
procedure
begin
if not LPatched then
begin
// La modification elle-meme n'a pas abouti : rien a adopter, et
// c'est le seul cas ou l'utilisateur perd sa saisie.
WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')');
Exit;
end;
AdoptVault(LNew);
FPending := not LPublished;
WebCall(Format('PM.setPending(%s)',
[LowerCase(BoolToStr(FPending, True))]));
if LPublished then
WebCall('PM.toast(' + JsArg(ALabel) + ')')
else
// DIRE LES DEUX : que c'est garde, et que le depot ne le sait pas
// encore. Un simple "echec" ferait retaper ce qui est deja la.
WebCall('PM.toast(' + JsArg(
ALabel + ' - saved here, not published yet') + ')');
end);
finally
LClient.Free;
// LA REMISE EN ETAT EST ICI, PAS DANS LE CHEMIN NOMINAL. Elle etait
// apres la boucle : une exception au rechiffrement ou au PUT la
// sautait, et la ligne restait sur "Saving..." pour toujours - sans
// message, puisque le message aussi etait dans la partie sautee.
// Quoi qu'il arrive au-dessus, l'ecran redevient utilisable.
TThread.Queue(nil,
procedure
begin
@@ -1069,6 +1057,108 @@ end;
///
/// L'ORDRE COMPTE. On publie d'abord, on efface ensuite : l'inverse perdrait le
/// coffre entier si la publication echouait.
/// PUBLIER CE QUI ATTEND ICI.
///
/// L'enregistrement hors connexion garde la modification dans le cache et
/// laisse le depot en arriere. Il faut donc un moment ou l'on rattrape, et
/// c'est la synchronisation - le geste par lequel on demande justement que
/// les deux cotes se reparlent.
///
/// FUSION, PAS ECRASEMENT : le depot a pu bouger pendant qu'on etait hors
/// ligne. MergeSnapshots arbitre entree par entree sur `updated_at`, donc ce
/// qui a ete modifie ailleurs plus recemment survit a notre publication.
procedure TForm8.PublishPending;
begin
if not FPending then Exit;
if FBusy then Exit;
if (FPlain = '') or (FSyncPwd = '') then Exit;
SetBusy(True);
Status('Publishing what was saved offline...');
TThread.CreateAnonymousThread(
procedure
var
LClient: TSyncClient;
LGet, LPut: TSyncResult;
LMerged, LBody, LErr: string;
LAttempt: Integer;
LDone: Boolean;
begin
LClient := TSyncClient.Create(FUrl, FToken);
LDone := False;
LErr := '';
LMerged := FPlain;
try
try
for LAttempt := 1 to 3 do
begin
LGet := LClient.Fetch;
if LGet.Status = ssOK then
begin
LMerged := MergeSnapshots(LMerged,
OpenContainer(LGet.Container, FSyncPwd));
FETag := LGet.ETag;
end
else if LGet.Status = ssNotModified then
// Le depot est reste ou nous l'avions laisse : rien a fondre, il
// ne manque que notre publication.
FETag := LGet.ETag
else
begin
LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]);
Break;
end;
LBody := EncryptContainerWithKey(LMerged, FKey, FKeySalt, FKeyIters);
LPut := LClient.Put(LBody, FETag, 'offline changes from phone');
if LPut.Status = ssOK then
begin
FETag := LPut.ETag;
try
SaveVault(LMerged, FETag, FSyncPwd, BiometricAvailable, False,
FPinSealed, FKey, FKeySalt, FKeyIters);
except
on E: Exception do Log(' cache not written: ' + E.Message);
end;
LDone := True;
Break;
end;
if LPut.Status <> ssConflict then
begin
LErr := Format('HTTP %d %s', [LPut.HttpCode, LPut.Error]);
Break;
end;
end;
if (not LDone) and (LErr = '') then
LErr := 'the remote kept changing - try again';
except
on E: Exception do LErr := E.ClassName + ': ' + E.Message;
end;
TThread.Queue(nil,
procedure
begin
if LDone then
begin
FPending := False;
AdoptVault(LMerged);
Log('pending changes published, new sha ' + FETag);
WebCall('PM.setPending(false)');
WebCall('PM.toast("Offline changes published")');
end
else
begin
Log('pending changes not published: ' + LErr);
WebCall('PM.toast(' + JsArg('Still not published - ' + LErr) + ')');
end;
end);
finally
LClient.Free;
TThread.Queue(nil, procedure begin SetBusy(False); end);
end;
end).Start;
end;
procedure TForm8.ReconcileWithRemote;
begin
if FBusy then Exit;
@@ -1988,6 +2078,16 @@ begin
Exit;
end;
// CE QUI ATTEND ICI PART D'ABORD. Tirer avant de publier ecraserait notre
// cache par la version du depot, et la modification faite hors connexion
// disparaitrait sans un mot - exactement ce que l'enregistrement hors
// connexion promet d'eviter.
if FPending then
begin
PublishPending;
Exit;
end;
// MEME PORTE QUE « Ouvrir le coffre ». Sans cela la protection ne tenait
// qu'a un bouton pres : synchroniser reconstruit le coffre depuis le serveur
// et l'affiche, ce qui revient exactement au meme resultat sans passer par