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:
+215
-115
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user