From 8796bb341dad45a5376d4d315521fc8f61d73a12 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:37:06 +0100 Subject: [PATCH] feat(android): HTTP client for the sync endpoint, with ETag reuse Fetches the opaque container and the ETag; a 304 skips both the download and the 4-second derivation, which is the whole point of keeping the tag between calls. Redirects are not followed - one to another host would send the bearer token there. Local testing goes through adb reverse tcp:8080 tcp:80, which makes the phone reach the PC as 127.0.0.1 and satisfies the endpoint's loopback exemption. That needs usesCleartextTraffic in the manifest, marked for removal before shipping. --- AndroidManifest.template.xml | 7 ++ android-client/PMA.SyncClient.pas | 143 ++++++++++++++++++++++++++++++ mainApk.pas | 131 ++++++++++++++++++++++++++- pmserverApk.dpr | 3 +- 4 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 android-client/PMA.SyncClient.pas diff --git a/AndroidManifest.template.xml b/AndroidManifest.template.xml index b19eb87..e295708 100644 --- a/AndroidManifest.template.xml +++ b/AndroidManifest.template.xml @@ -12,7 +12,14 @@ <%queries-child-elements%> + If-None-Match, et un 304 + /// evite de retelecharger ET de re-deriver la cle. + function Fetch(const AKnownETag: 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 + /// rendre la main vite plutot que de figer l'ecran. + property TimeoutMs: Integer read FTimeoutMs write FTimeoutMs; + end; + +implementation + +constructor TSyncClient.Create(const ABaseUrl, AToken: string); +begin + inherited Create; + FBaseUrl := ABaseUrl.Trim; + FToken := AToken.Trim; + FTimeoutMs := 15000; +end; + +function TSyncClient.Fetch(const AKnownETag: string): TSyncResult; +var + LHttp: THTTPClient; + LResp: IHTTPResponse; + LStream: TStringStream; +begin + Result := Default(TSyncResult); + Result.Status := ssNetworkError; + + if (FBaseUrl = '') or (FToken = '') then + begin + Result.Error := 'URL ou jeton non configure'; + Exit; + end; + + LHttp := THTTPClient.Create; + try + LHttp.ConnectionTimeout := FTimeoutMs; + LHttp.ResponseTimeout := FTimeoutMs; + // Pas de redirection suivie : une redirection vers un autre hote nous + // ferait envoyer le jeton ailleurs. + LHttp.HandleRedirects := False; + LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken; + if AKnownETag <> '' then + LHttp.CustomHeaders['If-None-Match'] := AKnownETag; + + LStream := TStringStream.Create('', TEncoding.UTF8); + try + try + LResp := LHttp.Get(FBaseUrl, LStream); + except + on E: Exception do + begin + Result.Status := ssNetworkError; + Result.Error := E.ClassName + ': ' + E.Message; + Exit; + end; + end; + + Result.HttpCode := LResp.StatusCode; + Result.ETag := LResp.HeaderValue['ETag']; + + case LResp.StatusCode of + 200: + begin + Result.Status := ssOK; + Result.Container := LStream.DataString; + // Un corps vide avec un 200 signale un proxy ou une erreur de + // configuration, pas un instantane : le signaler ici evite un + // "mot de passe incorrect" trompeur plus loin. + if Result.Container.Trim = '' then + begin + Result.Status := ssHttpError; + Result.Error := '200 avec un corps vide'; + end; + end; + 304: Result.Status := ssNotModified; + 403: + begin + Result.Status := ssRefused; + Result.Error := 'Le serveur exige HTTPS'; + end; + 404: Result.Status := ssNoSnapshot; + else + Result.Status := ssHttpError; + Result.Error := 'HTTP ' + LResp.StatusCode.ToString + ' ' + LResp.StatusText; + end; + finally + LStream.Free; + end; + finally + LHttp.Free; + end; +end; + +end. diff --git a/mainApk.pas b/mainApk.pas index 1cf1c83..bc9af20 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -21,14 +21,18 @@ uses System.Hash, System.Diagnostics, System.NetEncoding, FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types, - FMX.ScrollBox, FMX.Layouts; + FMX.ScrollBox, FMX.Layouts, FMX.Edit; type TForm8 = class(TForm) private FLog: TMemo; FBtn: TButton; + FBtnFetch: TButton; + FUrl, FToken, FPwd: TEdit; + FETag: string; // conserve entre deux appuis : c'est ce qui permet le 304 procedure RunSelfTest(Sender: TObject); + procedure RunFetch(Sender: TObject); procedure Log(const AMsg: string); public constructor Create(AOwner: TComponent); override; @@ -40,7 +44,7 @@ var implementation uses - PMA.Crypto, PMA.TestVector; + System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient; {$R *.fmx} @@ -49,12 +53,38 @@ begin inherited; Caption := 'PM — crypto self-test'; + // Champs de configuration. Rien n'est persiste : c'est un banc d'essai, et + // le mot de passe de sync ouvre le coffre entier — il n'a pas a trainer sur + // le disque tant que le stockage Keystore n'est pas ecrit. + FUrl := TEdit.Create(Self); + FUrl.Parent := Self; FUrl.Align := TAlignLayout.Top; + FUrl.Margins.Rect := RectF(8, 8, 8, 0); + FUrl.TextPrompt := 'http://127.0.0.1:8080/server-php/sync.php'; + + FToken := TEdit.Create(Self); + FToken.Parent := Self; FToken.Align := TAlignLayout.Top; + FToken.Margins.Rect := RectF(8, 4, 8, 0); + FToken.TextPrompt := 'jeton Bearer'; + + FPwd := TEdit.Create(Self); + FPwd.Parent := Self; FPwd.Align := TAlignLayout.Top; + FPwd.Margins.Rect := RectF(8, 4, 8, 0); + FPwd.TextPrompt := 'mot de passe de sync'; + FPwd.Password := True; + + FBtnFetch := TButton.Create(Self); + FBtnFetch.Parent := Self; FBtnFetch.Align := TAlignLayout.Top; + FBtnFetch.Margins.Rect := RectF(8, 4, 8, 0); + FBtnFetch.Height := 48; + FBtnFetch.Text := 'Telecharger et dechiffrer'; + FBtnFetch.OnClick := RunFetch; + FBtn := TButton.Create(Self); FBtn.Parent := Self; FBtn.Align := TAlignLayout.Top; - FBtn.Margins.Rect := RectF(8, 8, 8, 8); + FBtn.Margins.Rect := RectF(8, 4, 8, 8); FBtn.Height := 48; - FBtn.Text := 'Lancer le test'; + FBtn.Text := 'Test crypto (vecteur)'; FBtn.OnClick := RunSelfTest; FLog := TMemo.Create(Self); @@ -77,6 +107,99 @@ begin end); end; +procedure TForm8.RunFetch(Sender: TObject); +var + LUrl, LTok, LPwd: string; +begin + FLog.Lines.Clear; + FBtnFetch.Enabled := False; + // Lus ICI, sur le thread principal : toucher un controle FMX depuis le + // thread de travail est un plantage differe, pas une erreur de compilation. + LUrl := FUrl.Text.Trim; + LTok := FToken.Text.Trim; + LPwd := FPwd.Text; + + TThread.CreateAnonymousThread( + procedure + var + LClient: TSyncClient; + LRes: TSyncResult; + LPlain: string; + LSw: TStopwatch; + LJson: TJSONObject; + LArr: TJSONArray; + LLive, LTrash, I: Integer; + begin + LClient := TSyncClient.Create(LUrl, LTok); + try + Log('GET ' + LUrl); + if FETag <> '' then Log(' If-None-Match: ' + FETag); + LSw := TStopwatch.StartNew; + LRes := LClient.Fetch(FETag); + LSw.Stop; + Log(' HTTP ' + LRes.HttpCode.ToString + ' en ' + + LSw.ElapsedMilliseconds.ToString + ' ms'); + + case LRes.Status of + ssNotModified: + begin + Log(' ==> 304 : inchange, rien a re-dechiffrer'); + Exit; + end; + ssNoSnapshot: + begin + // Le serveur repond pareil dans les deux cas, par conception. + Log(' ==> 404 : pas d''instantane OU jeton invalide'); + Exit; + end; + ssRefused, ssHttpError, ssNetworkError: + begin + Log(' ==> echec : ' + LRes.Error); + Exit; + end; + end; + + Log(' recu ' + Length(LRes.Container).ToString + ' octets'); + if LRes.ETag <> '' then + begin + FETag := LRes.ETag; + Log(' ETag conserve : ' + FETag); + end; + + Log(''); + Log('Dechiffrement...'); + LSw := TStopwatch.StartNew; + LPlain := DecryptContainer(LRes.Container, LPwd); + LSw.Stop; + Log(' ' + LSw.ElapsedMilliseconds.ToString + ' ms pour ' + + Length(LPlain).ToString + ' caracteres'); + + // Compte vivant / corbeille : c'est ce que le vrai client devra + // afficher separement. + LJson := TJSONObject.ParseJSONValue(LPlain) as TJSONObject; + if LJson <> nil then + try + if LJson.TryGetValue('entries', LArr) then + begin + LLive := 0; LTrash := 0; + for I := 0 to LArr.Count - 1 do + if (LArr.Items[I] as TJSONObject).GetValue('deleted', 0) = 1 + then Inc(LTrash) else Inc(LLive); + Log(' ' + LLive.ToString + ' entrees, ' + + LTrash.ToString + ' en corbeille'); + end; + finally + LJson.Free; + end; + except + on E: Exception do + Log('ERREUR ' + E.ClassName + ' : ' + E.Message); + end; + LClient.Free; + TThread.Queue(nil, procedure begin FBtnFetch.Enabled := True; end); + end).Start; +end; + procedure TForm8.RunSelfTest(Sender: TObject); begin FLog.Lines.Clear; diff --git a/pmserverApk.dpr b/pmserverApk.dpr index b91a8bd..09f544e 100644 --- a/pmserverApk.dpr +++ b/pmserverApk.dpr @@ -5,7 +5,8 @@ uses FMX.Forms, mainApk in 'mainApk.pas' {Form8}, PMA.Crypto in 'android-client\PMA.Crypto.pas', - PMA.TestVector in 'android-client\PMA.TestVector.pas'; + PMA.TestVector in 'android-client\PMA.TestVector.pas', + PMA.SyncClient in 'android-client\PMA.SyncClient.pas'; {$R *.res}