diff --git a/android-client/PLAN.md b/android-client/PLAN.md index b66bf5a..5084a0d 100644 --- a/android-client/PLAN.md +++ b/android-client/PLAN.md @@ -730,9 +730,40 @@ téléphone qu'on voudra ensuite ouvrir sur le poste demandera un import. 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 - ajoute un dépôt. C'est là que la fusion revient, et elle a été évitée - jusqu'ici pour de bonnes raisons. +4. **Réconcilier avec la synchronisation** — fait le 2026-08-21. + + Jusqu'ici les deux situations ne se rencontraient pas : un coffre local + n'avait pas de dépôt, un coffre tiré d'un dépôt n'avait pas de conteneur + local. Le jour où les deux existent, la règle « le conteneur local fait + autorité » fait que les enregistrements **n'atteignent jamais le dépôt** — + et les deux divergent en silence, la pire des issues. + + *Sync* fond donc les deux, **une fois**, puis **efface le conteneur local** : + le dépôt devient l'autorité et l'appareil se comporte dès lors comme + n'importe quel autre. C'est ce qui rend la divergence impossible plutôt + qu'improbable. L'ordre compte — on publie d'abord, on efface ensuite ; + l'inverse perdrait le coffre entier si la publication échouait. + + **Le distant sert de base, jamais le local.** Il porte ce que le téléphone + ne sait pas reconstruire : le contenu des pièces jointes, l'avatar, la + couleur des dossiers, et ce que le poste ajoutera demain. Repartir du local + amputerait tout cela, et le poste adopterait la version amputée au tirage + suivant — la perte silencieuse contre laquelle `PMA.VaultPatch` existe. + + L'arbitrage est celui du poste, terme à terme : dernier écrit gagnant sur + `updated_at`, une pierre tombale l'emporte sur une entrée plus ancienne + qu'elle, mais **pas** sur une entrée modifiée après — celle-là a été + ressuscitée volontairement. Les horodatages étant de l'ISO 8601 UTC des deux + côtés, millisecondes comprises, l'ordre lexicographique **est** l'ordre + chronologique : aucune date à analyser. + + Dépôt vide : rien à fusionner, on publie. Le 404 de Gitea étant ambigu — + fichier absent ou dépôt qu'on n'a pas le droit de voir — on ne cherche pas à + trancher : publier tranche, un jeton sans droit échouant au PUT avant que + rien n'ait été effacé. + + `MergeSelfCheck` vérifie les quatre règles au démarrage en débogage. La + fusion ne tourne jamais contre un vrai coffre avant d'avoir tourné là. ### Vérifié : le poste reprend ce que le téléphone crée — 2026-08-21 diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas index de38dc3..837d88e 100644 --- a/android-client/PMA.Store.pas +++ b/android-client/PMA.Store.pas @@ -147,6 +147,9 @@ procedure ClearVault; function HasLocalContainer: Boolean; function LoadLocalContainer: string; procedure SaveLocalContainer(const AContainer: string); +/// Efface le conteneur local. A n'appeler QU'APRES avoir publie son contenu : +/// c'est le geste qui fait passer l'autorite au depot, et il est sans retour. +procedure DropLocalContainer; /// Chemin du fichier, pour le journal et les tests. function VaultPath: string; @@ -250,6 +253,12 @@ begin TFile.WriteAllText(LocalContainerPath, AContainer, TEncoding.UTF8); end; +procedure DropLocalContainer; +begin + if TFile.Exists(LocalContainerPath) then + TFile.Delete(LocalContainerPath); +end; + function VaultPath: string; begin // GetHomePath sur Android = /data/data//files, prive a l'app. diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas index a3965f0..6620687 100644 --- a/android-client/PMA.VaultPatch.pas +++ b/android-client/PMA.VaultPatch.pas @@ -65,6 +65,25 @@ function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, /// une suppression qui s'y arreterait ne se propagerait donc nulle part. function DeleteEntry(const APlainJson, AUuid: string): string; +/// Fond l'instantane LOCAL dans l'instantane DISTANT et rend le resultat. +/// +/// LE DISTANT SERT DE BASE, jamais le local. Il porte ce que le telephone ne +/// sait pas reconstruire - le contenu des pieces jointes, l'avatar du compte, +/// la couleur des dossiers, et tout ce que le poste ajoutera demain. Repartir +/// du local amputerait tout cela, et le poste adopterait la version amputee au +/// tirage suivant : la meme perte silencieuse et definitive contre laquelle +/// cette unite entiere est ecrite. +/// +/// L'arbitrage est celui du poste, terme a terme : dernier ecrit gagnant sur +/// `updated_at`, et une pierre tombale l'emporte sur une entree plus ancienne +/// qu'elle. Les horodatages sont de l'ISO 8601 UTC des deux cotes, +/// millisecondes comprises, donc l'ordre lexicographique EST l'ordre +/// chronologique - aucune date a analyser. +/// +/// Une entree sans uuid est ignoree : rien ne permettrait de la reconnaitre +/// chez l'autre, et l'ajouter en creerait un doublon a chaque fusion. +function MergeSnapshots(const ALocalJson, ARemoteJson: string): string; + /// 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 /// fois : il n'y a qu'une source d'heure ici, et elle est en UTC. @@ -100,6 +119,114 @@ begin end; end; +/// L'heure de suppression retenue pour un uuid, ou '' s'il n'en a pas. +/// La plus recente des deux listes : une entree ressuscitee puis resupprimee +/// doit rester supprimee. +function TombstoneTime(const AStones: TJSONArray; const AUuid: string): string; +var + I: Integer; + LObj: TJSONObject; + LWhen: string; +begin + Result := ''; + if AStones = nil then Exit; + for I := 0 to AStones.Count - 1 do + begin + if not (AStones.Items[I] is TJSONObject) then Continue; + LObj := AStones.Items[I] as TJSONObject; + if LObj.GetValue('uuid', '') <> AUuid then Continue; + LWhen := LObj.GetValue('deleted_at', ''); + if LWhen > Result then Result := LWhen; + end; +end; + +/// Copie profonde d'un objet JSON : le reparser est le moyen le plus court, et +/// le seul qui n'oublie aucun champ. +function CloneJson(const AObj: TJSONObject): TJSONObject; +begin + Result := TJSONObject.ParseJSONValue(AObj.ToJSON) as TJSONObject; +end; + +function MergeSnapshots(const ALocalJson, ARemoteJson: string): string; +var + LLocal, LMerged: TJSONObject; + LLocalEntries, LOutEntries, LLocalStones, LOutStones: TJSONArray; + LEntry, LOther: TJSONObject; + I, J: Integer; + LUuid, LMine, LTheirs, LKilled: string; + LFound: Boolean; +begin + LLocal := TJSONObject.ParseJSONValue(ALocalJson) as TJSONObject; + if LLocal = nil then raise EPMAPatch.Create('Instantane local illisible'); + try + LMerged := TJSONObject.ParseJSONValue(ARemoteJson) as TJSONObject; + if LMerged = nil then raise EPMAPatch.Create('Instantane distant illisible'); + try + if not LMerged.TryGetValue('entries', LOutEntries) then + raise EPMAPatch.Create('Instantane distant sans tableau "entries"'); + if not LMerged.TryGetValue('tombstones', LOutStones) then + begin + LOutStones := TJSONArray.Create; + LMerged.AddPair('tombstones', LOutStones); + end; + + // 1. Les pierres tombales locales rejoignent celles du distant. Avant les + // entrees : l'etape suivante s'en sert pour arbitrer. + if LLocal.TryGetValue('tombstones', LLocalStones) then + for I := 0 to LLocalStones.Count - 1 do + if LLocalStones.Items[I] is TJSONObject then + LOutStones.AddElement(CloneJson(LLocalStones.Items[I] as TJSONObject)); + + // 2. Chaque entree locale : absente du distant on l'ajoute, presente on + // garde la plus recente des deux. + if LLocal.TryGetValue('entries', LLocalEntries) then + for I := 0 to LLocalEntries.Count - 1 do + begin + if not (LLocalEntries.Items[I] is TJSONObject) then Continue; + LEntry := LLocalEntries.Items[I] as TJSONObject; + LUuid := LEntry.GetValue('uuid', ''); + if LUuid = '' then Continue; + LMine := LEntry.GetValue('updated_at', ''); + + LFound := False; + for J := 0 to LOutEntries.Count - 1 do + begin + if not (LOutEntries.Items[J] is TJSONObject) then Continue; + LOther := LOutEntries.Items[J] as TJSONObject; + if LOther.GetValue('uuid', '') <> LUuid then Continue; + LFound := True; + LTheirs := LOther.GetValue('updated_at', ''); + if LMine > LTheirs then + begin + LOutEntries.Remove(J).Free; + LOutEntries.AddElement(CloneJson(LEntry)); + end; + Break; + end; + if not LFound then LOutEntries.AddElement(CloneJson(LEntry)); + end; + + // 3. Une pierre tombale posterieure a l'entree la retire. Posterieure + // SEULEMENT : une entree modifiee apres coup a ete ressuscitee + // volontairement, et la retuer effacerait ce geste. + for J := LOutEntries.Count - 1 downto 0 do + begin + if not (LOutEntries.Items[J] is TJSONObject) then Continue; + LOther := LOutEntries.Items[J] as TJSONObject; + LKilled := TombstoneTime(LOutStones, LOther.GetValue('uuid', '')); + if (LKilled <> '') and (LKilled > LOther.GetValue('updated_at', '')) then + LOutEntries.Remove(J).Free; + end; + + Result := LMerged.ToJSON; + finally + LMerged.Free; + end; + finally + LLocal.Free; + end; +end; + function DeleteEntry(const APlainJson, AUuid: string): string; var LRoot, LEntry, LStone: TJSONObject; diff --git a/mainApk.pas b/mainApk.pas index 2727212..78f7802 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -255,6 +255,7 @@ type {$IFDEF DEBUG} procedure CryptoSelfCheck; procedure PatchSelfCheck; + procedure MergeSelfCheck; {$ENDIF} procedure OpenEntry(AVaultIndex: Integer); /// Ouvre un conteneur en evitant la derivation quand la cle en main @@ -271,6 +272,8 @@ type procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); /// Supprime l'entree ouverte, pierre tombale comprise. Sans retour. procedure DeleteCurrentEntry; + /// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot. + procedure ReconcileWithRemote; procedure ReopenEntryByUuid(const AUuid: string); procedure OnClipTimer(Sender: TObject); procedure OnDeadline(Sender: TObject); @@ -598,6 +601,7 @@ begin TFile.WriteAllText(LPath, LContainer, TEncoding.UTF8); Log('selfcheck: container written for cross-check'); PatchSelfCheck; + MergeSelfCheck; except on E: Exception do Log('selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message); @@ -654,6 +658,82 @@ end; /// PAR UUID ET NON PAR INDICE : le coffre vient d'etre remplace par celui du /// serveur, et rien ne garantit que la meme entree occupe le meme rang - un /// tri se fait sur le nom affiche, qu'une autre machine a pu changer. +/// Verifie les QUATRE regles de la fusion, celles dont un ecart se paierait en +/// entrees perdues ou ressuscitees. Elle ne tourne jamais contre un vrai coffre +/// avant d'avoir tourne ici. +procedure TForm8.MergeSelfCheck; +const + LOCAL = + '{"entries":[' + + '{"uuid":"only-local","updated_at":"2026-01-01T00:00:00.000Z"},' + + '{"uuid":"both","updated_at":"2026-06-01T00:00:00.000Z","title":"newer here"},' + + '{"uuid":"killed-there","updated_at":"2026-01-01T00:00:00.000Z"},' + + '{"uuid":"revived","updated_at":"2026-07-01T00:00:00.000Z"}],' + + '"tombstones":[]}'; + REMOTE = + '{"avatar_b64":"KEEPME","entries":[' + + '{"uuid":"only-remote","updated_at":"2026-01-01T00:00:00.000Z",' + + '"attachments":[{"content_b64":"KEEPTOO"}]},' + + '{"uuid":"both","updated_at":"2026-02-01T00:00:00.000Z","title":"older"},' + + '{"uuid":"killed-there","updated_at":"2026-01-01T00:00:00.000Z"},' + + '{"uuid":"revived","updated_at":"2026-01-01T00:00:00.000Z"}],' + + '"tombstones":[' + + '{"uuid":"killed-there","deleted_at":"2026-05-01T00:00:00.000Z"},' + + '{"uuid":"revived","deleted_at":"2026-05-01T00:00:00.000Z"}]}'; + + function Find(const AArr: TJSONArray; const AUuid: string): TJSONObject; + var + I: Integer; + begin + Result := nil; + for I := 0 to AArr.Count - 1 do + if (AArr.Items[I] is TJSONObject) and + ((AArr.Items[I] as TJSONObject).GetValue('uuid', '') = AUuid) then + Exit(AArr.Items[I] as TJSONObject); + end; + +var + LOut: string; + LRoot: TJSONObject; + LArr: TJSONArray; + LOk: Boolean; +begin + try + LOut := MergeSnapshots(LOCAL, REMOTE); + LRoot := TJSONObject.ParseJSONValue(LOut) as TJSONObject; + if LRoot = nil then + begin + Log('merge selfcheck: result is not JSON'); + Exit; + end; + try + LArr := LRoot.GetValue('entries') as TJSONArray; + LOk := True; + // Ce que le distant seul portait survit, contenu des pieces jointes + // compris - c'est la raison pour laquelle il sert de base. + LOk := LOk and (Find(LArr, 'only-remote') <> nil); + LOk := LOk and (LRoot.GetValue('avatar_b64', '') = 'KEEPME'); + // Ce que le local seul portait est ajoute. + LOk := LOk and (Find(LArr, 'only-local') <> nil); + // Meme uuid des deux cotes : le plus recent gagne. + LOk := LOk and (Find(LArr, 'both') <> nil) and + (Find(LArr, 'both').GetValue('title', '') = 'newer here'); + // Pierre tombale posterieure a l'entree : elle disparait. + LOk := LOk and (Find(LArr, 'killed-there') = nil); + // Pierre tombale ANTERIEURE : l'entree a ete ressuscitee volontairement, + // et la retuer effacerait ce geste. + LOk := LOk and (Find(LArr, 'revived') <> nil); + if LOk then Log('merge selfcheck: the four rules hold') + else Log('merge selfcheck: FAILED - ' + Copy(LOut, 1, 400)); + finally + LRoot.Free; + end; + except + on E: Exception do + Log('merge selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message); + end; +end; + {$ENDIF} procedure TForm8.ReopenEntryByUuid(const AUuid: string); @@ -953,6 +1033,139 @@ end; /// au prochain tirage. /// /// La page demande confirmation avant d'en arriver ici. +/// RECONCILIER - un coffre ne sur le telephone auquel on donne un depot. +/// +/// Jusqu'ici les deux situations ne se rencontraient pas : un coffre local +/// n'avait pas de depot, un coffre tire d'un depot n'avait pas de conteneur +/// local. Le jour ou les deux existent, la regle "le conteneur local fait +/// autorite" fait que les enregistrements n'atteignent jamais le depot - et les +/// deux divergent en silence, ce qui est la pire des issues. +/// +/// La fusion se fait UNE FOIS, puis le conteneur local est efface : le depot +/// devient l'autorite, et l'appareil se comporte des lors comme n'importe quel +/// autre. C'est ce qui rend la divergence impossible plutot qu'improbable. +/// +/// L'ORDRE COMPTE. On publie d'abord, on efface ensuite : l'inverse perdrait le +/// coffre entier si la publication echouait. +procedure TForm8.ReconcileWithRemote; +begin + if FBusy then Exit; + if not HasLocalContainer then + begin + Status('No local vault to reconcile'); + Exit; + end; + if FSyncPwd = '' then + begin + Status('Open the vault first'); + Exit; + end; + SetBusy(True); + Status('Reconciling...'); + + TThread.CreateAnonymousThread( + procedure + var + LClient: TSyncClient; + LGet, LPut: TSyncResult; + LLocal, LRemote, LMerged, LBody, LErr: string; + LAttempt: Integer; + LDone, LFirstPush: Boolean; + begin + LClient := TSyncClient.Create(FUrl, FToken); + LDone := False; + LFirstPush := False; + LErr := ''; + try + try + LLocal := OpenContainer(LoadLocalContainer, FSyncPwd); + + for LAttempt := 1 to 3 do + begin + LGet := LClient.Fetch; + if LGet.Status = ssNoSnapshot then + begin + // Depot vide : rien a fusionner, seulement a publier. Le cas le + // plus frequent - on donne un depot neuf a un coffre. + // + // 404 EST AMBIGU chez Gitea : fichier absent, ou depot prive qu'on + // n'a pas le droit de voir. On ne cherche pas a trancher ici - + // publier tranche pour nous, puisqu'un jeton sans droit echouera au + // PUT et que rien n'aura ete efface d'ici la. + LMerged := LLocal; + LGet.ETag := ''; + LFirstPush := True; + end + else if LGet.Status = ssOK then + begin + LRemote := OpenContainer(LGet.Container, FSyncPwd); + LMerged := MergeSnapshots(LLocal, LRemote); + end + 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, LGet.ETag, 'reconciled from phone'); + if LPut.Status = ssOK then + begin + FETag := LPut.ETag; + LDone := True; + Break; + end; + if LPut.Status = ssConflict then Continue; + LErr := Format('write failed (HTTP %d) %s', [LPut.HttpCode, LPut.Error]); + Break; + end; + + if (not LDone) and (LErr = '') then + LErr := 'the remote kept changing - try again'; + + if LDone then + begin + // PUBLIE, DONC PLUS BESOIN DU CONTENEUR LOCAL. C'est ce geste qui + // passe l'autorite au depot ; le faire avant la publication aurait + // perdu le coffre. + DropLocalContainer; + try + SaveVault(LMerged, FETag, FSyncPwd, BiometricAvailable, False, + FPinSealed, FKey, FKeySalt, FKeyIters); + except + on E: Exception do Log(' cache not written: ' + E.Message); + end; + if LFirstPush then Log('Reconcile: repository was empty - published') + else Log('Reconcile: merged with the repository'); + end; + + TThread.Queue(nil, + procedure + begin + if LDone then + begin + AdoptVault(LMerged); + WebCall('PM.toast("Reconciled - the repository is now the source")'); + end + else + WebCall('PM.toast(' + JsArg('Not reconciled - ' + LErr) + ')'); + end); + except + on E: Exception do + begin + LErr := E.ClassName + ': ' + E.Message; + Log('Reconcile FAILED: ' + LErr); + TThread.Queue(nil, procedure + begin WebCall('PM.toast(' + JsArg('Not reconciled - ' + LErr) + ')'); end); + end; + end; + finally + LClient.Free; + TThread.Queue(nil, procedure begin SetBusy(False); ShowUnlock; end); + end; + end).Start; +end; + procedure TForm8.DeleteCurrentEntry; var LUuid, LTitle: string; @@ -1610,6 +1823,16 @@ begin end; WebCall('PM.clearLog()'); + // UN COFFRE NE ICI A QUI L'ON DONNE UN DEPOT : il faut fondre les deux, pas + // ecraser l'un par l'autre. Sans cela le conteneur local resterait + // l'autorite, les enregistrements n'atteindraient jamais le depot, et les + // deux divergeraient en silence. + if HasLocalContainer then + begin + ReconcileWithRemote; + 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