Skip to content

Commit 538ba08

Browse files
committed
Prefer vector app icon rendering
1 parent 8124d6f commit 538ba08

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

packages/shared/src/apk.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,11 @@ async function readBestIconCandidate(source, resources, resourceId, candidates,
881881
return adaptiveIcon;
882882
}
883883

884+
const vectorIcon = await readVectorIconFromXmlCandidates(source, resources, candidates);
885+
if (vectorIcon) {
886+
return vectorIcon;
887+
}
888+
884889
const candidate = selectBestIconCandidate(source.zipEntries, candidates);
885890
if (!candidate) {
886891
return readBestIconFromXmlCandidates(source, resources, candidates, seen);
@@ -947,6 +952,33 @@ async function readBestIconFromXmlCandidates(source, resources, candidates, seen
947952
return null;
948953
}
949954

955+
async function readVectorIconFromXmlCandidates(source, resources, candidates) {
956+
const xmlCandidates = selectIconXmlCandidates(source.zipEntries, candidates);
957+
for (const candidate of xmlCandidates) {
958+
const entry = source.zipEntries.get(candidate.path);
959+
if (!entry) {
960+
continue;
961+
}
962+
963+
try {
964+
const xmlBytes = await extractSourceEntry(source, entry);
965+
const vectorIcon = await renderVectorDrawableIcon(xmlBytes, source, resources, candidate);
966+
if (vectorIcon) {
967+
return vectorIcon;
968+
}
969+
970+
const shapeIcon = renderShapeDrawableIcon(xmlBytes, resources, candidate);
971+
if (shapeIcon) {
972+
return shapeIcon;
973+
}
974+
} catch {
975+
// Unsupported XML should not block bitmap fallbacks.
976+
}
977+
}
978+
979+
return null;
980+
}
981+
950982
async function readAdaptiveIconFromXmlCandidates(source, resources, candidates, seen) {
951983
const xmlCandidates = selectIconXmlCandidates(source.zipEntries, candidates);
952984
for (const candidate of xmlCandidates) {
@@ -1363,6 +1395,30 @@ async function renderVectorDrawableIcon(xmlBytes, source, resources, candidate)
13631395

13641396
const svg = renderStandaloneVectorSvg(layer);
13651397
const bytes = new TextEncoder().encode(svg);
1398+
if (bytes.byteLength > MAX_APP_ICON_BYTES) {
1399+
return null;
1400+
}
1401+
1402+
return {
1403+
resourceId: null,
1404+
path: candidate.path,
1405+
mimeType: "image/svg+xml",
1406+
size: bytes.byteLength,
1407+
dataUri: `data:image/svg+xml;base64,${bytesToBase64(bytes)}`,
1408+
};
1409+
}
1410+
1411+
function renderShapeDrawableIcon(xmlBytes, resources, candidate) {
1412+
const layer = buildShapeDrawableSvgLayer(xmlBytes, resources, candidate);
1413+
if (!layer) {
1414+
return null;
1415+
}
1416+
1417+
const svg = renderStandaloneShapeSvg(layer);
1418+
const bytes = new TextEncoder().encode(svg);
1419+
if (bytes.byteLength > MAX_APP_ICON_BYTES) {
1420+
return null;
1421+
}
13661422

13671423
return {
13681424
resourceId: null,
@@ -1377,6 +1433,7 @@ export const __apkTestInternals = {
13771433
collectNativeLibraries,
13781434
parseElfInfo,
13791435
parseZipEntries,
1436+
readBestIconCandidate,
13801437
renderVectorDrawableIcon,
13811438
};
13821439

@@ -1550,6 +1607,16 @@ function renderStandaloneVectorSvg(layer) {
15501607
].join("");
15511608
}
15521609

1610+
function renderStandaloneShapeSvg(layer) {
1611+
const size = ADAPTIVE_ICON_SIZE;
1612+
return [
1613+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}">`,
1614+
layer.defs,
1615+
`<rect x="0" y="0" width="${size}" height="${size}" fill="${escapeXmlAttribute(layer.paint)}"/>`,
1616+
"</svg>",
1617+
].join("");
1618+
}
1619+
15531620
function parseDrawableXmlElements(xmlBytes) {
15541621
const bytes = toUint8Array(xmlBytes);
15551622
if (readUint16(bytes, 0) !== RES_XML_TYPE) {

packages/shared/test/apk-icon.test.mjs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,65 @@ test("preserves vector even-odd fill rules", async () => {
5858
assert.match(svg, /clip-rule="evenodd"/u);
5959
});
6060

61+
test("prefers renderable vector drawables over bitmap app icon candidates", async () => {
62+
const vectorPath = "res/mipmap-anydpi-v26/ic_launcher.xml";
63+
const bitmapPath = "res/mipmap-xxxhdpi/ic_launcher.png";
64+
const source = createIconSource({
65+
[vectorPath]: textEncoder.encode(`
66+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
67+
android:width="108dp"
68+
android:height="108dp"
69+
android:viewportWidth="24"
70+
android:viewportHeight="24">
71+
<path
72+
android:pathData="M12,2l9,20h-18z"
73+
android:fillColor="#2f80ed"/>
74+
</vector>
75+
`),
76+
[bitmapPath]: createPngHeaderBytes(),
77+
});
78+
const icon = await __apkTestInternals.readBestIconCandidate(
79+
source,
80+
createIconResources(),
81+
0x7f010001,
82+
[
83+
createIconCandidate(bitmapPath, 640),
84+
createIconCandidate(vectorPath, 0xfffe),
85+
],
86+
);
87+
88+
assert.equal(icon.mimeType, "image/svg+xml");
89+
assert.equal(icon.path, vectorPath);
90+
assert.match(decodeSvgDataUri(icon.dataUri), /<path /u);
91+
});
92+
93+
test("prefers simple shape drawables over bitmap app icon candidates", async () => {
94+
const shapePath = "res/drawable/ic_launcher.xml";
95+
const bitmapPath = "res/drawable-xxxhdpi/ic_launcher.png";
96+
const source = createIconSource({
97+
[shapePath]: textEncoder.encode(`
98+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
99+
android:shape="rectangle">
100+
<solid android:color="#2f80ed"/>
101+
</shape>
102+
`),
103+
[bitmapPath]: createPngHeaderBytes(),
104+
});
105+
const icon = await __apkTestInternals.readBestIconCandidate(
106+
source,
107+
createIconResources(),
108+
0x7f010002,
109+
[
110+
createIconCandidate(bitmapPath, 640),
111+
createIconCandidate(shapePath, 0),
112+
],
113+
);
114+
115+
assert.equal(icon.mimeType, "image/svg+xml");
116+
assert.equal(icon.path, shapePath);
117+
assert.match(decodeSvgDataUri(icon.dataUri), /fill="#2f80ed"/u);
118+
});
119+
61120
test("annotates native libraries with ELF page size and ZIP alignment", async () => {
62121
const elfBytes = createElf64WithLoadAlignment(0x4000);
63122
const zipBytes = createStoredZipWithSingleEntry("lib/arm64-v8a/libpage.so", elfBytes, 4096);
@@ -81,6 +140,42 @@ function decodeSvgDataUri(dataUri) {
81140
return Buffer.from(base64, "base64").toString("utf8");
82141
}
83142

143+
function createIconSource(files) {
144+
const fileEntries = Object.entries(files).map(([path, bytes]) => [
145+
path,
146+
{
147+
path,
148+
compressedSize: bytes.byteLength,
149+
uncompressedSize: bytes.byteLength,
150+
},
151+
]);
152+
const fileMap = new Map(Object.entries(files));
153+
return {
154+
zipEntries: new Map(fileEntries),
155+
extractEntry: async (entry) => fileMap.get(entry.path),
156+
};
157+
}
158+
159+
function createIconResources() {
160+
return {
161+
resolveColor: () => null,
162+
resolveFiles: () => [],
163+
};
164+
}
165+
166+
function createIconCandidate(path, density) {
167+
return {
168+
path,
169+
density,
170+
typeName: path.includes("/mipmap") ? "mipmap" : "drawable",
171+
isDefaultConfig: path.includes("/mipmap/") || path.includes("/drawable/"),
172+
};
173+
}
174+
175+
function createPngHeaderBytes() {
176+
return new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
177+
}
178+
84179
function createElf64WithLoadAlignment(alignment) {
85180
const bytes = new Uint8Array(0x80);
86181
const view = new DataView(bytes.buffer);

0 commit comments

Comments
 (0)