diff --git a/android-client/PLAN.md b/android-client/PLAN.md index 772e09f..362d35c 100644 --- a/android-client/PLAN.md +++ b/android-client/PLAN.md @@ -170,7 +170,7 @@ tentée d'abord : MSBuild l'ignore, comme il ignore toutes les autres ici. régénération de `web.res` — l'oublier embarque la page d'avant l'édition, et le symptôme se lit alors comme un bug applicatif. -## 1 quater. OUVERT — la comparaison de sha ne se declenche pas +## 1 quater. ~~La comparaison de sha ne se declenche pas~~ — RESOLU le 2026-08-17 Mesure sur appareil, deux ouvertures successives du cache : diff --git a/android-client/PMA.SyncClient.pas b/android-client/PMA.SyncClient.pas index 6ee60b2..bb3490e 100644 --- a/android-client/PMA.SyncClient.pas +++ b/android-client/PMA.SyncClient.pas @@ -39,7 +39,11 @@ type ssNoSnapshot, // 404 : pas d'instantane, OU jeton invalide ssRefused, // 403 : HTTPS exige par le serveur ssHttpError, // autre code - ssNetworkError // rien n'a repondu + ssNetworkError, // rien n'a repondu + /// 409 : le sha envoye n'est plus celui du fichier - un autre appareil a + /// publie entre notre lecture et notre ecriture. L'appelant doit relire, + /// refusionner et renvoyer, pas reessayer tel quel. + ssConflict ); TSyncResult = record @@ -69,6 +73,15 @@ type /// n'y ressemble pas est de toute facon inutilisable. function IsGitea: Boolean; + /// Publie un conteneur. ASha est celui du fichier qu'on a lu : Gitea s'en + /// sert pour refuser (409) une ecriture si quelqu'un d'autre a publie + /// entre-temps. Vide = creation d'un fichier qui n'existait pas. + /// + /// GITEA SEULEMENT pour l'instant. L'endpoint PHP repond 405 au PUT, et + /// lui ajouter l'ecriture n'a plus d'interet maintenant que le depot fait + /// le meme travail avec un controle de concurrence eprouve. + function Put(const AContainerJson, ASha, AMessage: string): TSyncResult; + property BaseUrl: string read FBaseUrl write FBaseUrl; property Token: string read FToken write FToken; /// Volontairement court : sur mobile, un reseau qui ne repond pas doit @@ -86,6 +99,24 @@ begin Result := FBaseUrl.ToLower.Contains('/api/v1/repos/'); end; +/// Le sha du fichier ecrit, tire de la reponse au PUT : Gitea rend +/// { "content": { "sha": ... }, "commit": { ... } }. Vide si la forme change - +/// l'appelant relira, ce qui coute un appel mais ne perd rien. +function ShaFromPutResponse(const ABody: string): string; +var + LRoot, LContent: TJSONObject; +begin + Result := ''; + LRoot := TJSONObject.ParseJSONValue(ABody) as TJSONObject; + if LRoot = nil then Exit; + try + if LRoot.TryGetValue('content', LContent) then + Result := LContent.GetValue('sha', ''); + finally + LRoot.Free; + end; +end; + /// Extrait le conteneur et le sha de la reponse de l'API des contenus. Rend /// faux, avec la raison, si la reponse n'a pas la forme attendue - une URL de /// dépôt juste a cote rend du JSON, mais pas celui-la. @@ -137,6 +168,95 @@ begin end; end; +function TSyncClient.Put(const AContainerJson, ASha, AMessage: string): TSyncResult; +var + LHttp: THTTPClient; + LResp: IHTTPResponse; + LEnv: TJSONObject; + LReq, LResync: TStringStream; + LB64: TBase64Encoding; +begin + Result := Default(TSyncResult); + Result.Status := ssNetworkError; + + if not IsGitea then + begin + Result.Status := ssHttpError; + Result.Error := 'Ecriture disponible seulement vers un depot Gitea'; + Exit; + end; + if (FBaseUrl = '') or (FToken = '') then + begin + Result.Error := 'URL ou jeton non configure'; + Exit; + end; + + LEnv := TJSONObject.Create; + LB64 := TBase64Encoding.Create(0); // pas de coupure : voir PMA.Crypto + try + LEnv.AddPair('message', AMessage); + LEnv.AddPair('content', + LB64.EncodeBytesToString(TEncoding.UTF8.GetBytes(AContainerJson))); + // OMIS, PAS VIDE, quand on cree le fichier : Gitea lit la presence de la + // cle comme « remplace exactement cette version-la », et une chaine vide + // n'est pas la meme chose que son absence. + if ASha <> '' then LEnv.AddPair('sha', ASha); + // `branch` reste absent : le depot utilise alors sa branche par defaut, + // plutot qu'un nom qu'on devinerait. + LReq := TStringStream.Create(LEnv.ToJSON, TEncoding.UTF8); + LResync := TStringStream.Create('', TEncoding.UTF8); + try + LHttp := THTTPClient.Create; + try + LHttp.ConnectionTimeout := FTimeoutMs; + LHttp.ResponseTimeout := FTimeoutMs; + LHttp.HandleRedirects := False; + LHttp.CustomHeaders['Authorization'] := 'token ' + FToken; + LHttp.CustomHeaders['Content-Type'] := 'application/json'; + try + LResp := LHttp.Put(FBaseUrl, LReq, LResync); + except + on E: Exception do + begin + Result.Status := ssNetworkError; + Result.Error := E.ClassName + ': ' + E.Message; + Exit; + end; + end; + Result.HttpCode := LResp.StatusCode; + case LResp.StatusCode of + 200, 201: + begin + Result.Status := ssOK; + // Le sha du fichier VENANT D'ETRE ECRIT, a garder pour la + // prochaine ecriture - sans lui il faudrait relire pour publier. + Result.ETag := ShaFromPutResponse(LResync.DataString); + end; + 409: Result.Status := ssConflict; + else + Result.Status := ssHttpError; + // LE CORPS, TOUJOURS. Il etait remplace par un message fige sur 401 + // et 403 - « jeton sans droit d'ecriture » - ce qui est une + // interpretation, pas une observation : le meme code couvre un depot + // en lecture seule, une branche protegee, un jeton d'un autre compte. + // Gitea dit lequel dans son corps, et le taire envoie chercher a + // cote. + Result.Error := Format('HTTP %d %s - %s', [LResp.StatusCode, + LResp.StatusText, Copy(LResync.DataString, 1, 300)]); + end; + finally + LHttp.Free; + end; + finally + LResync.Free; + LReq.Free; + end; + finally + LB64.Free; + LEnv.Free; + end; +end; + constructor TSyncClient.Create(const ABaseUrl, AToken: string); begin inherited Create; diff --git a/android-client/web/app.html b/android-client/web/app.html index 4dc656e..8d7c746 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -214,6 +214,10 @@ + +