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.
This commit is contained in:
r-zakarya
2026-08-14 23:37:06 +01:00
parent c684d9073f
commit 8796bb341d
4 changed files with 279 additions and 5 deletions
+7
View File
@@ -12,7 +12,14 @@
<queries>
<%queries-child-elements%>
</queries>
<!-- usesCleartextTraffic : TEST UNIQUEMENT. Android bloque le trafic en
clair depuis la version 9, y compris vers 127.0.0.1 ; sans cet
attribut l'essai local via "adb reverse" echoue avec une erreur
reseau opaque. A RETIRER avant diffusion : l'endpoint de production
est en HTTPS, et le jeton Bearer circule dans un en-tete a chaque
appel. -->
<application
android:usesCleartextTraffic="true"
android:persistent="%persistent%"
android:restoreAnyVersion="%restoreAnyVersion%"
android:label="%label%"
+143
View File
@@ -0,0 +1,143 @@
unit PMA.SyncClient;
{
Lecture de l'instantane chiffre depuis l'endpoint PHP (server-php/sync.php).
Ne dechiffre rien : il ramene des octets opaques et l'ETag qui les identifie.
Le dechiffrement est le travail de PMA.Crypto, et il coute 4 secondes — d'ou
l'importance de l'ETag, qui evite de le refaire quand rien n'a change.
Le serveur repond 404 aussi bien pour un jeton invalide que pour un
instantane absent : c'est voulu (un appelant non authentifie n'apprend pas
ce qui existe), et le client ne peut donc pas les distinguer. Le message
utilisateur doit couvrir les deux cas.
}
interface
uses
System.SysUtils, System.Classes, System.Net.HttpClient,
System.Net.URLClient, System.Net.HttpClientComponent;
type
TSyncStatus = (
ssOK, // 200 : nouveau contenu dans Container
ssNotModified, // 304 : l'ETag detenu est toujours le bon
ssNoSnapshot, // 404 : pas d'instantane, OU jeton invalide
ssRefused, // 403 : HTTPS exige par le serveur
ssHttpError, // autre code
ssNetworkError // rien n'a repondu
);
TSyncResult = record
Status: TSyncStatus;
Container: string; // le JSON chiffre, vide sauf sur ssOK
ETag: string; // a repasser au prochain appel
HttpCode: Integer;
Error: string; // message brut, pour le journal
end;
TSyncClient = class
private
FBaseUrl: string;
FToken: string;
FTimeoutMs: Integer;
public
constructor Create(const ABaseUrl, AToken: string);
/// GET de l'instantane. AKnownETag non vide -> 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.
+127 -4
View File
@@ -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<TJSONArray>('entries', LArr) then
begin
LLive := 0; LTrash := 0;
for I := 0 to LArr.Count - 1 do
if (LArr.Items[I] as TJSONObject).GetValue<Integer>('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;
+2 -1
View File
@@ -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}