feat(android): tell when the repository has moved, without adopting it

A probe every ten minutes compares the remote fingerprint to ours and lights
an indicator. It does not pull: adopting on its own while someone is reading
an entry would change it under their eyes. The user decides when.

The body is downloaded anyway - Gitea's contents API cannot answer without it
- but it is thrown away undecrypted: no derivation, no write, just a string
comparison. The cost is network, not time or battery.

It rides the idle timer rather than a second one. That timer already beats
every 15 seconds for auto-lock; counting its beats costs an integer, where
another timer costs an object, a lifecycle, and one more chance to forget it
on shutdown.

Two indicators, two colours, two gestures. Orange: things wait HERE and the
repository does not know. Blue: the repository holds things we do not have.
One dot for both would say "something is wrong" without saying what - and one
is resolved by publishing, the other by fetching.
This commit is contained in:
r-zakarya
2026-08-22 15:28:41 +01:00
parent 92ceb1e8c9
commit 4ae845bc13
3 changed files with 119 additions and 7 deletions
+83
View File
@@ -85,6 +85,12 @@ const
// par un autre chemin et n'attend jamais.
REFRESH_MIN_MINUTES = 15;
// Verification du depot : un battement du minuteur d'inactivite vaut 15 s,
// donc quarante battements font dix minutes. Assez rare pour ne rien peser
// sur une liaison mobile, assez frequent pour qu'on l'apprenne avant d'avoir
// modifie la meme entree des deux cotes.
PROBE_EVERY_TICKS = 40;
// FIRSTRUN_TOKEN et TEST_PWD viennent d'un fichier LOCAL, hors depot : le
// jeton authentifie l'appareil, le mot de passe ouvre le coffre entier. Ni
// l'un ni l'autre n'a sa place dans un depot, meme sans remote. Absents apres
@@ -150,6 +156,10 @@ type
FKeyIters: Integer;
/// Des modifications sont rangees ici sans avoir atteint le depot.
FPending: Boolean;
/// Le depot porte quelque chose que nous n'avons pas.
FRemoteAhead: Boolean;
/// Ticks du minuteur d'inactivite depuis la derniere verification du depot.
FTicksSinceProbe: Integer;
/// 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
@@ -291,6 +301,8 @@ type
/// Publie ce qui a ete enregistre hors connexion. Sans effet s'il n'y a
/// rien en attente.
procedure PublishPending;
/// Demande au depot s'il a bouge, sans rien adopter.
procedure ProbeRemote;
procedure ReopenEntryByUuid(const AUuid: string);
procedure OnClipTimer(Sender: TObject);
procedure OnDeadline(Sender: TObject);
@@ -920,6 +932,7 @@ begin
begin
FETag := LPut.ETag;
LPublished := True;
FRemoteAhead := False;
Log('save: published, new sha ' + LPut.ETag);
Break;
end;
@@ -1145,6 +1158,7 @@ begin
if LDone then
begin
FPending := False;
FRemoteAhead := False;
SetPendingFlag(False);
AdoptVault(LMerged);
Log('pending changes published, new sha ' + FETag);
@@ -1937,6 +1951,11 @@ begin
WebCall(Format('PM.setPending(%s)',
[LowerCase(BoolToStr(FPending, True))]));
WebCall(Format('PM.setRemoteAhead(%s)',
[LowerCase(BoolToStr(FRemoteAhead, True))]));
// Le compteur repart de zero : on vient de regarder le depot, ou de s'en
// passer sciemment. Le sonder dans la foulee ne dirait rien de neuf.
FTicksSinceProbe := 0;
try
LSw := TStopwatch.StartNew;
AdoptVault(LPlain);
@@ -2298,6 +2317,8 @@ begin
// hors connexion disparaissait alors sans un mot, et c'est exactement
// ce que l'enregistrement hors ligne promet d'eviter.
//
// Le depot vient d'etre lu : ce qu'il portait est ici.
FRemoteAhead := False;
// On garde le drapeau : fondre ne publie pas.
if FPending and (FPlain <> '') then
begin
@@ -2473,7 +2494,69 @@ begin
begin
Log('Locked after ' + AUTOLOCK_MINUTES.ToString + ' min idle');
LockVault;
Exit;
end;
// LE MEME MINUTEUR, PAS UN SECOND. Il bat deja toutes les 15 s pour
// l'inactivite ; compter ses battements coute un entier, la ou un timer de
// plus coute un objet, un cycle de vie et une occasion de l'oublier a l'arret.
Inc(FTicksSinceProbe);
if FTicksSinceProbe >= PROBE_EVERY_TICKS then
begin
FTicksSinceProbe := 0;
ProbeRemote;
end;
end;
/// DEMANDER SI LE DEPOT A BOUGE, SANS RIEN ADOPTER.
///
/// Adopter de soi-meme pendant que quelqu'un lit une entree la ferait changer
/// sous ses yeux. On se contente donc de comparer les empreintes et d'allumer
/// un temoin : c'est l'utilisateur qui decide quand tirer.
///
/// LE CORPS EST TELECHARGE QUAND MEME - l'API des contenus de Gitea ne sait
/// pas repondre sans lui - mais il est jete sans etre dechiffre : pas de
/// derivation, pas d'ecriture, seulement une comparaison de chaines. Le prix
/// est en reseau, pas en temps ni en batterie.
procedure TForm8.ProbeRemote;
begin
if FBusy or (FUrl = '') or (FToken = '') then Exit;
// Un coffre ne ici n'a pas de depot a interroger.
if HasLocalContainer then Exit;
if FETag = '' then Exit;
TThread.CreateAnonymousThread(
procedure
var
LClient: TSyncClient;
LGet: TSyncResult;
LAhead: Boolean;
begin
LAhead := False;
LClient := TSyncClient.Create(FUrl, FToken);
try
try
LGet := LClient.Fetch(FETag);
LAhead := (LGet.Status = ssOK) and (LGet.ETag <> '')
and not SameText(LGet.ETag.Trim, FETag.Trim);
except
// Hors reseau : ce n'est pas une nouvelle du depot, c'est une absence
// de nouvelle. Le temoin ne change pas.
Exit;
end;
finally
LClient.Free;
end;
if not LAhead then Exit;
TThread.Queue(nil,
procedure
begin
if FRemoteAhead then Exit; // deja dit
FRemoteAhead := True;
Log('remote has moved - sync to bring it in');
WebCall('PM.setRemoteAhead(true)');
end);
end).Start;
end;
procedure TForm8.Status(const AMsg: string);