feat(android): an entry can get its icon, through the same relay as the desktop

Fetching https://<site>/favicon.ico directly would resolve a DNS name - and
open a connection - to EVERY domain kept in the vault. The list of sites where
someone holds an account is precisely what this vault protects; handing it to
the network for a sixteen-pixel drawing would be a poor trade. The relay sees
only that someone is interested in one domain, which is why the call is asked
for entry by entry and never happens on its own.

Two tries: the full host, then the second-level domain - chat.qwen.ai has no
icon at the relay, qwen.ai does. The type comes from the first bytes, never
from the extension: the relay answers PNG under a .ico address.

A dedicated patch rather than PatchEntryField, because icon_custom is a NUMBER
in the snapshot and writing it as a string would give "0" - which the desktop
reads with GetValue<Boolean> and would choke on. It is set to zero: the icon
comes from an automatic lookup, not from a choice, and that flag is what stops
the desktop from ever replacing a chosen icon during a refresh.

Base64 without line breaks. Delphi's encoder wraps at 76 characters by
default, a wrapped data: URI does not display, and that trap has already been
paid once on the containers.
This commit is contained in:
r-zakarya
2026-08-22 15:39:19 +01:00
parent 73e3faa24c
commit 2010dec22a
6 changed files with 332 additions and 6 deletions
+168
View File
@@ -0,0 +1,168 @@
unit PMA.Favicon;
{
L'icone d'un site, par le relais de DuckDuckGo.
MEME SOURCE QUE LE POSTE, et pour la meme raison. Aller chercher
`https://<site>/favicon.ico` en direct ferait resoudre un DNS - et ouvrir une
connexion - vers CHAQUE domaine range dans le coffre. La liste des sites ou
quelqu'un a un compte est precisement ce que ce coffre protege : la donner au
reseau pour un dessin de seize pixels serait un mauvais marche.
Le relais, lui, ne voit qu'une chose : que quelqu'un s'interesse a ce domaine.
C'est deja quelque chose, d'ou le caractere facultatif de l'appel - il ne part
que lorsqu'on le demande, entree par entree.
DEUX ESSAIS : l'hote complet, puis le domaine de second niveau. `chat.qwen.ai`
n'a pas d'icone chez le relais, `qwen.ai` en a une.
}
interface
uses
System.SysUtils;
/// Rend une URI `data:` prete a etre rangee dans `icon_b64`, ou '' si rien
/// n'a ete trouve - AError dit alors pourquoi.
function FetchFaviconDataUri(const ASite: string; out AError: string): string;
/// L'hote d'une adresse, sans schema ni chemin. Public parce que c'est aussi
/// ce qu'il faut pour decider s'il y a quelque chose a chercher.
function HostOf(const ASite: string): string;
implementation
uses
System.Classes, System.NetEncoding, System.Net.HttpClient,
System.Net.URLClient;
const
// 64 Ko : une icone de site en fait quelques-uns. Au-dela, ce n'est pas une
// icone, et l'accepter ferait gonfler l'instantane que chaque enregistrement
// republie en entier.
MAX_BYTES = 64 * 1024;
TIMEOUT_MS = 5000;
function HostOf(const ASite: string): string;
var
I: Integer;
begin
Result := Trim(ASite);
if Result = '' then Exit;
I := Pos('://', Result);
if I > 0 then Result := Copy(Result, I + 3, MaxInt);
// Chemin, requete, ancre, et les identifiants d'une URL a la user@host.
I := Pos('/', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
I := Pos('?', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
I := Pos('#', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
I := LastDelimiter('@', Result);
if I > 0 then Result := Copy(Result, I + 1, MaxInt);
I := Pos(':', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
Result := LowerCase(Trim(Result));
// Un site note "Gitea" plutot qu'en adresse n'a pas d'hote : sans point, il
// n'y a rien a demander au relais.
if Pos('.', Result) = 0 then Result := '';
end;
/// Le domaine de second niveau, ou '' s'il n'y a rien de plus court a essayer.
function SecondLevel(const AHost: string): string;
var
LParts: TArray<string>;
begin
Result := '';
LParts := AHost.Split(['.']);
if Length(LParts) < 3 then Exit; // deja un SLD
Result := LParts[High(LParts) - 1] + '.' + LParts[High(LParts)];
end;
/// Le type d'apres les premiers octets, jamais d'apres l'extension : le relais
/// rend du PNG sous une adresse en .ico, et un type faux ferait afficher un
/// cadre vide sans rien dire.
function SniffMime(const AData: TBytes): string;
begin
Result := '';
if Length(AData) < 4 then Exit;
if (AData[0] = $89) and (AData[1] = Ord('P')) then Result := 'image/png'
else if (AData[0] = $FF) and (AData[1] = $D8) then Result := 'image/jpeg'
else if (AData[0] = Ord('G')) and (AData[1] = Ord('I')) then Result := 'image/gif'
else if (AData[0] = 0) and (AData[1] = 0) and (AData[2] = 1) then
Result := 'image/x-icon'
else if (AData[0] = Ord('<')) then Result := 'image/svg+xml';
end;
function TryHost(const AHost: string; out AData: TBytes): Boolean;
var
LHttp: THTTPClient;
LStream: TBytesStream;
LResp: IHTTPResponse;
begin
Result := False;
AData := nil;
LHttp := THTTPClient.Create;
try
LHttp.ConnectionTimeout := TIMEOUT_MS;
LHttp.ResponseTimeout := TIMEOUT_MS;
LHttp.MaxRedirects := 3;
LStream := TBytesStream.Create;
try
try
LResp := LHttp.Get('https://icons.duckduckgo.com/ip3/' + AHost + '.ico',
LStream);
except
Exit;
end;
if LResp.StatusCode <> 200 then Exit;
if (LStream.Size = 0) or (LStream.Size > MAX_BYTES) then Exit;
AData := Copy(LStream.Bytes, 0, LStream.Size);
Result := True;
finally
LStream.Free;
end;
finally
LHttp.Free;
end;
end;
function FetchFaviconDataUri(const ASite: string; out AError: string): string;
var
LHost, LSld, LMime: string;
LData: TBytes;
LB64: TBase64Encoding;
begin
Result := '';
AError := '';
LHost := HostOf(ASite);
if LHost = '' then
begin
AError := 'this entry has no web address';
Exit;
end;
if not TryHost(LHost, LData) then
begin
LSld := SecondLevel(LHost);
if (LSld = '') or not TryHost(LSld, LData) then
begin
AError := 'no icon found for ' + LHost;
Exit;
end;
end;
LMime := SniffMime(LData);
if LMime = '' then
begin
AError := 'what came back is not an image';
Exit;
end;
// SANS COUPURE DE LIGNE. L'encodeur de Delphi coupe a 76 caracteres par
// defaut ; une URI data: coupee ne s'affiche pas, et le piege s'est deja
// paye une fois sur les conteneurs.
LB64 := TBase64Encoding.Create(0);
try
Result := 'data:' + LMime + ';base64,' + LB64.EncodeBytesToString(LData);
finally
LB64.Free;
end;
end;
end.
+54
View File
@@ -87,6 +87,18 @@ function DuplicateEntry(const APlainJson, AUuid: string): string;
function AttachmentContent(const APlainJson, AUuid: string;
AIndex: Integer): string;
/// Pose l'icone d'une entree, et le drapeau de provenance avec elle.
///
/// PAS PatchEntryField : `icon_custom` est un NOMBRE dans l'instantane, et
/// l'ecrire comme une chaine donnerait `"icon_custom": "0"` - que le poste lit
/// avec GetValue<Boolean>, qui echouerait sur une chaine. Deux champs de types
/// differents, donc une fonction qui les connait tous les deux.
///
/// AUri vide efface l'icone. Le drapeau retombe alors a zero comme sur le
/// poste : une icone absente n'est pas une icone choisie.
function PatchEntryIcon(const APlainJson, AUuid, AUri: string;
ACustom: Boolean): 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
@@ -297,6 +309,48 @@ begin
end;
end;
function PatchEntryIcon(const APlainJson, AUuid, AUri: string;
ACustom: Boolean): string;
var
LRoot, LEntry: TJSONObject;
LEntries: TJSONArray;
I: Integer;
LFound: Boolean;
begin
if AUuid = '' then
raise EPMAPatch.Create('Entree sans uuid : rien a retrouver');
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Instantane illisible');
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
raise EPMAPatch.Create('Instantane sans tableau "entries"');
LFound := False;
for I := 0 to LEntries.Count - 1 do
begin
if not (LEntries.Items[I] is TJSONObject) then Continue;
LEntry := LEntries.Items[I] as TJSONObject;
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
LEntry.RemovePair('icon_b64').Free;
LEntry.AddPair('icon_b64', AUri);
LEntry.RemovePair('icon_custom').Free;
LEntry.AddPair('icon_custom',
TJSONNumber.Create(Ord(ACustom and (AUri <> ''))));
LEntry.RemovePair('updated_at').Free;
LEntry.AddPair('updated_at', NowUtcIso);
LFound := True;
Break;
end;
if not LFound then
raise EPMAPatch.CreateFmt('Entree %s introuvable', [AUuid]);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
function AttachmentContent(const APlainJson, AUuid: string;
AIndex: Integer): string;
var
+17 -3
View File
@@ -140,12 +140,26 @@ prouve rien.
empêche quelqu'un tenant le téléphone déverrouillé de lever la protection
sans la connaître.
### G. Pièces jointes
### G. Icônes
1. Ouvrir une entrée dont le *Site* est une adresse — `git.ai-agents4you.com`.
2. Trois points → **Fetch icon** → l'icône doit apparaître dans la liste.
3. Une entrée dont le site n'est **pas** une adresse (*Gitea*, ou vide) →
refus *« no web address to look up »*. Sans point dans le nom, il n'y a
rien à demander au relais.
4. Un domaine à trois niveaux (`chat.qwen.ai`) doit retomber sur le second
niveau : c'est le deuxième essai.
5. **Remove icon** → l'entrée reprend son initiale.
6. Synchroniser depuis le poste : l'icône doit y arriver, et le poste ne doit
**pas** la traiter comme une icône choisie — c'est le rôle du drapeau de
provenance, mis à zéro par une recherche automatique.
### H. Pièces jointes
1. Ligne *Attachment* → flèche de téléchargement → le fichier apparaît dans
*Téléchargements* sous son nom, accents et arabe compris.
### H. Code de déverrouillage
### I. Code de déverrouillage
1. Poser un code, ouvrir le coffre, fermer, rouvrir → il est demandé.
2. Code faux → *« Wrong unlock code »*, et **Open vault reste offert** (le
@@ -155,7 +169,7 @@ prouve rien.
4. Contre-épreuve : taper n'importe quoi dans le champ du code puis *Sync* →
le code **ne doit pas changer**.
### I. Empaquetage
### J. Empaquetage
1. `build-apk.ps1 -Release -Keystore ... -Alias ...` → la sortie doit afficher
`Signer #1 certificate DN: CN=R Zakarya`. Si elle affiche `CN=Android
+9 -1
View File
@@ -1260,7 +1260,9 @@ function closeMenu() {
/// un menu qui apparait loin du doigt ne se rattache a rien.
function openMenu() {
var m = document.getElementById('menu');
m.innerHTML = '<button data-dup>Duplicate entry</button>'
m.innerHTML = '<button data-icon="fetch">Fetch icon</button>'
+ '<button data-icon="clear">Remove icon</button>'
+ '<button data-dup>Duplicate entry</button>'
+ '<button class="danger" data-del>Delete entry</button>';
m.hidden = false;
}
@@ -1274,6 +1276,12 @@ document.getElementById('kebab').addEventListener('click', function (ev) {
function onMenuClick(ev) {
var b = ev.target.closest('button');
if (!b) return;
var ic = b.getAttribute('data-icon');
if (ic !== null) {
closeMenu();
send('icon?a=' + ic);
return;
}
if (b.getAttribute('data-dup') !== null) {
closeMenu();
send('dupentry');
+82 -1
View File
@@ -306,6 +306,8 @@ type
/// Retire le code de deverrouillage : le cache se rescelle sous la seule
/// cle de l'appareil. Exige un coffre ouvert.
procedure ClearUnlockCode;
/// Va chercher l'icone du site de l'entree ouverte, ou l'efface.
procedure SetEntryIcon(AFetch: Boolean);
procedure ReopenEntryByUuid(const AUuid: string);
procedure OnClipTimer(Sender: TObject);
procedure OnDeadline(Sender: TObject);
@@ -330,7 +332,7 @@ implementation
uses
PMA.Crypto, PMA.SyncClient, PMA.Store, PMA.Biometric, PMA.Totp,
PMA.ScreenGuard, PMA.Clipboard, PMA.Theme, PMA.WebUI, PMA.VaultPatch,
PMA.SaveFile, System.JSON;
PMA.SaveFile, PMA.Favicon, System.JSON;
{$R *.fmx}
@@ -1619,6 +1621,9 @@ begin
TNetEncoding.URL.Decode(Param('k')),
TNetEncoding.URL.Decode(Param('d')))
else if LAction = 'icon' then
SetEntryIcon(Param('a') = 'fetch')
else if LAction = 'pinclear' then
ClearUnlockCode
@@ -2545,6 +2550,82 @@ end;
/// s'est laisse lire que sous le bon code. Sans cette condition, n'importe qui
/// tenant l'appareil deverrouille pourrait retirer la protection sans la
/// connaitre.
/// L'ICONE DE L'ENTREE OUVERTE : la chercher, ou l'effacer.
///
/// LE SEUL APPEL DE CETTE APPLICATION QUI SORT AILLEURS QUE VERS LE DEPOT, et
/// il ne part que sur demande, entree par entree. Il dit a DuckDuckGo qu'on
/// s'interesse a ce domaine - c'est peu, mais ce n'est pas rien, et cela ne
/// doit pas se produire dans le dos de quelqu'un.
///
/// La recherche est hors du thread principal : cinq secondes d'attente
/// figeraient l'ecran. L'ecriture, elle, repasse par CommitChange - donc par
/// le meme chemin que tout le reste, avec sa publication et son hors-ligne.
procedure TForm8.SetEntryIcon(AFetch: Boolean);
var
LUuid, LSite, LTitle: string;
begin
if FBusy then Exit;
if FCurrent.Uuid = '' then
begin
RefuseChange('This entry has no uuid');
Exit;
end;
LUuid := FCurrent.Uuid;
LTitle := FCurrent.Display;
LSite := FCurrent.Site;
if not AFetch then
begin
CommitChange('Icon removed: ' + LTitle, 'icon removed from phone', LUuid,
function (APlain: string): string
begin
Result := PatchEntryIcon(APlain, LUuid, '', False);
end);
Exit;
end;
if HostOf(LSite) = '' then
begin
RefuseChange('This entry has no web address to look up');
Exit;
end;
SetBusy(True);
TThread.CreateAnonymousThread(
procedure
var
LUri, LErr: string;
begin
LUri := '';
LErr := '';
try
LUri := FetchFaviconDataUri(LSite, LErr);
except
on E: Exception do LErr := E.ClassName + ': ' + E.Message;
end;
TThread.Queue(nil,
procedure
begin
SetBusy(False);
if LUri = '' then
begin
Log('icon: ' + LErr);
WebCall('PM.toast(' + JsArg('No icon - ' + LErr) + ')');
Exit;
end;
Log(Format('icon: %d chars for %s', [Length(LUri), HostOf(LSite)]));
// Provenance a FAUX : elle vient d'une recherche automatique, pas
// d'un choix. Le poste ne remplace jamais une icone choisie lors
// d'un rafraichissement, et ce drapeau est ce qui le lui dit.
CommitChange('Icon set: ' + LTitle, 'icon fetched from phone', LUuid,
function (APlain: string): string
begin
Result := PatchEntryIcon(APlain, LUuid, LUri, False);
end);
end);
end).Start;
end;
procedure TForm8.ClearUnlockCode;
begin
if FBusy then Exit;
+2 -1
View File
@@ -16,7 +16,8 @@ uses
PMA.Theme in 'android-client\PMA.Theme.pas',
PMA.WebUI in 'android-client\PMA.WebUI.pas',
PMA.VaultPatch in 'android-client\PMA.VaultPatch.pas',
PMA.SaveFile in 'android-client\PMA.SaveFile.pas';
PMA.SaveFile in 'android-client\PMA.SaveFile.pas',
PMA.Favicon in 'android-client\PMA.Favicon.pas';
{$R *.res}