diff --git a/android-client/web/app.html b/android-client/web/app.html
index d514f2e..ca36130 100644
--- a/android-client/web/app.html
+++ b/android-client/web/app.html
@@ -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') + 'Please wait...';
diff --git a/mainApk.pas b/mainApk.pas
index 482bfb5..dcbff20 100644
--- a/mainApk.pas
+++ b/mainApk.pas
@@ -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