authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-07 18:20:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 18:20:46-07:00
log892de47f0625183edd111dd36df45b156185bbf2
tree8421d26acf07d35c092637a6b2e8d9e9932b28fe
parentd081ab7abc16a21570d3d96948acb6c1f8d847a4
signature Signed by PGP key B5690EEEBB952194

fix(mobile): fit the Android splash icon to its circular mask (#10620)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

6 files changed, 94 insertions(+), 27 deletions(-)

apps/mobile/app.config.ts+12
...@@ -39,6 +39,7 @@ const DEVELOPMENT_ASSETS = {...@@ -39,6 +39,7 @@ const DEVELOPMENT_ASSETS = {
39 androidAdaptiveForeground,39 androidAdaptiveForeground,
40 androidAdaptiveBackgroundColor: "#347FF8",40 androidAdaptiveBackgroundColor: "#347FF8",
41 androidAdaptiveBackgroundImage: "./assets/android-icon-background-dev.png",41 androidAdaptiveBackgroundImage: "./assets/android-icon-background-dev.png",
42 androidSplashIcon: "./assets/android-splash-icon-dev.png",
42 androidMonochromeIcon: "./assets/android-icon-mark.png",43 androidMonochromeIcon: "./assets/android-icon-mark.png",
43 androidNotificationIcon: "./assets/android-notification-icon.png",44 androidNotificationIcon: "./assets/android-notification-icon.png",
44 androidNotificationColor: "#00639B",45 androidNotificationColor: "#00639B",
...@@ -51,6 +52,7 @@ const PREVIEW_ASSETS = {...@@ -51,6 +52,7 @@ const PREVIEW_ASSETS = {
51 androidAdaptiveForeground,52 androidAdaptiveForeground,
52 androidAdaptiveBackgroundColor: "#111533",53 androidAdaptiveBackgroundColor: "#111533",
53 androidAdaptiveBackgroundImage: "./assets/android-icon-background-nightly.png",54 androidAdaptiveBackgroundImage: "./assets/android-icon-background-nightly.png",
55 androidSplashIcon: "./assets/android-splash-icon-nightly.png",
54 androidMonochromeIcon: "./assets/android-icon-mark.png",56 androidMonochromeIcon: "./assets/android-icon-mark.png",
55 androidNotificationIcon: "./assets/android-notification-icon.png",57 androidNotificationIcon: "./assets/android-notification-icon.png",
56 androidNotificationColor: "#7565C7",58 androidNotificationColor: "#7565C7",
...@@ -63,6 +65,7 @@ const RELEASE_ASSETS = {...@@ -63,6 +65,7 @@ const RELEASE_ASSETS = {
63 androidAdaptiveForeground,65 androidAdaptiveForeground,
64 androidAdaptiveBackgroundColor: "#000000",66 androidAdaptiveBackgroundColor: "#000000",
65 androidAdaptiveBackgroundImage: undefined,67 androidAdaptiveBackgroundImage: undefined,
68 androidSplashIcon: "./assets/android-splash-icon-prod.png",
66 androidMonochromeIcon: "./assets/android-icon-mark.png",69 androidMonochromeIcon: "./assets/android-icon-mark.png",
67 androidNotificationIcon: "./assets/android-notification-icon.png",70 androidNotificationIcon: "./assets/android-notification-icon.png",
68 androidNotificationColor: "#FFFFFF",71 androidNotificationColor: "#FFFFFF",
...@@ -339,6 +342,15 @@ const config: ExpoConfig = {...@@ -339,6 +342,15 @@ const config: ExpoConfig = {
339 image: variant.assets.splashIcon,342 image: variant.assets.splashIcon,
340 backgroundColor: "#0a0a0a",343 backgroundColor: "#0a0a0a",
341 },344 },
345 android: {
346 // Android 12+ masks the splash icon to a circle over the central two thirds of
347 // its 288dp canvas, so the iOS export's corners get cut. A full-canvas image of
348 // the composed adaptive layers puts the wordmark in the same frame the launcher
349 // icon uses.
350 image: variant.assets.androidSplashIcon,
351 imageWidth: 288,
352 dark: { image: variant.assets.androidSplashIcon },
353 },
342 },354 },
343 ],355 ],
344 [356 [
apps/mobile/assets/android-splash-icon-dev.png created
Binary files /dev/null and b/apps/mobile/assets/android-splash-icon-dev.png differ
apps/mobile/assets/android-splash-icon-nightly.png created
Binary files /dev/null and b/apps/mobile/assets/android-splash-icon-nightly.png differ
apps/mobile/assets/android-splash-icon-prod.png created
Binary files /dev/null and b/apps/mobile/assets/android-splash-icon-prod.png differ
assets/README.md+7-5
...@@ -49,17 +49,19 @@ Verify every result is 1024×1024 and has the classic macOS safe area: an 824×8...@@ -49,17 +49,19 @@ Verify every result is 1024×1024 and has the classic macOS safe area: an 824×8
4949
50Do not edit the generated PNG or ICO files directly.50Do not edit the generated PNG or ICO files directly.
5151
52## Android adaptive icon layers52## Android launcher and splash artwork
5353
54Android masks the central 72dp of a 108dp adaptive canvas, so the Icon Composer exports cannot be54Android masks the central 72dp of a 108dp adaptive canvas, and the Android 12+ splash screen masks
55used directly: their rounded-square silhouette gets framed again and the wordmark is cropped. The55the central two thirds of a 288dp canvas, so the Icon Composer exports cannot be used directly:
56Android layers are instead rendered from the same Icon Composer SVG sources by56their rounded-square silhouette gets framed again and the wordmark is cropped. The Android artwork
57`vp run icons:export:android`:57is instead rendered from the same Icon Composer SVG sources by `vp run icons:export:android`:
5858
59- `apps/mobile/assets/android-icon-foreground.png`: the shared transparent wordmark, sized to stay59- `apps/mobile/assets/android-icon-foreground.png`: the shared transparent wordmark, sized to stay
60 inside the safe zone60 inside the safe zone
61- `apps/mobile/assets/android-icon-background-dev.png` and `-nightly.png`: full-bleed variant61- `apps/mobile/assets/android-icon-background-dev.png` and `-nightly.png`: full-bleed variant
62 artwork (blueprint grid and annotations; night sky and clouds). Production uses a solid color.62 artwork (blueprint grid and annotations; night sky and clouds). Production uses a solid color.
63- `apps/mobile/assets/android-splash-icon-*.png`: the two layers composed into one 288dp image, so
64 the splash mask reproduces the launcher icon's framing.
6365
64Rerun the export after changing a layer SVG. `android-icon-mark.png` remains a flat silhouette for66Rerun the export after changing a layer SVG. `android-icon-mark.png` remains a flat silhouette for
65Android's monochrome themed icon.67Android's monochrome themed icon.
scripts/export-android-icons.ts+75-22
...@@ -1,12 +1,17 @@...@@ -1,12 +1,17 @@
1#!/usr/bin/env node1#!/usr/bin/env node
22
3// Renders the Android adaptive-icon layers from the Icon Composer SVG sources.3// Renders the Android launcher and splash artwork from the Icon Composer SVG sources.
4//4//
5// Icon Composer exports already contain a rounded-square silhouette, and Android masks5// Icon Composer exports already contain a rounded-square silhouette, and Android masks
6// the central 72dp of a 108dp adaptive canvas, so exporting them as a foreground produces6// the central 72dp of a 108dp adaptive canvas, so exporting them as a foreground produces
7// a double-framed icon with the letters cropped by the mask. Instead, each variant gets a7// a double-framed icon with the letters cropped by the mask. Instead, each variant gets a
8// full-bleed background layer (the artwork behind the wordmark) and a shared transparent8// full-bleed background layer (the artwork behind the wordmark) and a shared transparent
9// foreground that keeps the wordmark inside the safe zone.9// foreground that keeps the wordmark inside the safe zone.
10//
11// The Android 12+ splash screen masks its icon to a circle covering the central two thirds
12// of a 288dp canvas, which is the same proportion the launcher crops. Composing the two
13// adaptive layers into one 288dp image therefore makes the splash frame the wordmark
14// exactly like the launcher icon does.
1015
11import * as NodeRuntime from "@effect/platform-node/NodeRuntime";16import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
12import * as NodeServices from "@effect/platform-node/NodeServices";17import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -19,8 +24,10 @@ import sharp from "sharp";...@@ -19,8 +24,10 @@ import sharp from "sharp";
1924
20type IconVariant = "dev" | "nightly" | "prod";25type IconVariant = "dev" | "nightly" | "prod";
2126
22// 108dp at xxxhdpi. Expo's prebuild derives every density bucket from this.27// 108dp at xxxhdpi. Expo's prebuild derives every launcher density bucket from this.
23const CANVAS = 432;28const ADAPTIVE_CANVAS = 432;
29// 288dp at xxxhdpi: the full Android 12+ splash canvas, so the icon needs no upscaling.
30const SPLASH_CANVAS = 1152;
24// Icon Composer's layer sources use a 128pt viewBox; the wordmark path spans this box.31// Icon Composer's layer sources use a 128pt viewBox; the wordmark path spans this box.
25const TEXT = { x: 15.53, y: 37, width: 94.5, height: 57 };32const TEXT = { x: 15.53, y: 37, width: 94.5, height: 57 };
26// Wordmark width as a fraction of the 108dp canvas. The visible area is 72dp (66dp33// Wordmark width as a fraction of the 108dp canvas. The visible area is 72dp (66dp
...@@ -31,21 +38,23 @@ const WORDMARK_FRACTION = 0.48;...@@ -31,21 +38,23 @@ const WORDMARK_FRACTION = 0.48;
31const COMPOSER_CANVAS_PT = 1024;38const COMPOSER_CANVAS_PT = 1024;
32const SVG_DENSITY = 300;39const SVG_DENSITY = 300;
33const OUTPUT_DIRECTORY = "apps/mobile/assets";40const OUTPUT_DIRECTORY = "apps/mobile/assets";
41// Production has no background artwork, so its splash composes onto the adaptive color.
42const PRODUCTION_BACKGROUND_COLOR = "#000000";
3443
35export class AndroidIconRenderError extends Schema.TaggedErrorClass<AndroidIconRenderError>()(44export class AndroidIconRenderError extends Schema.TaggedErrorClass<AndroidIconRenderError>()(
36 "AndroidIconRenderError",45 "AndroidIconRenderError",
37 { layer: Schema.String, cause: Schema.Defect() },46 { layer: Schema.String, cause: Schema.Defect() },
38) {}47) {}
3948
40const wordmarkTransform = () => {49const wordmarkTransform = (size: number) => {
41 const scale = (CANVAS * WORDMARK_FRACTION) / TEXT.width;50 const scale = (size * WORDMARK_FRACTION) / TEXT.width;
42 const tx = (CANVAS - TEXT.width * scale) / 2 - TEXT.x * scale;51 const tx = (size - TEXT.width * scale) / 2 - TEXT.x * scale;
43 const ty = (CANVAS - TEXT.height * scale) / 2 - TEXT.y * scale;52 const ty = (size - TEXT.height * scale) / 2 - TEXT.y * scale;
44 return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${scale.toFixed(4)})`;53 return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${scale.toFixed(4)})`;
45};54};
4655
47const canvasSvg = (inner: string) =>56const canvasSvg = (size: number, inner: string) =>
48 `<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS}" height="${CANVAS}" viewBox="0 0 ${CANVAS} ${CANVAS}" fill="none">${inner}</svg>`;57 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none">${inner}</svg>`;
4958
50// The layer sources clip to a 10pt rounded rectangle for the iOS silhouette. Android59// The layer sources clip to a 10pt rounded rectangle for the iOS silhouette. Android
51// applies its own mask, so the layer must bleed to the canvas edge.60// applies its own mask, so the layer must bleed to the canvas edge.
...@@ -73,6 +82,15 @@ const composite = (...@@ -73,6 +82,15 @@ const composite = (
73 catch: (cause) => new AndroidIconRenderError({ layer, cause }),82 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
74 });83 });
7584
85const solidCanvas = (layer: string, size: number, background: string) =>
86 Effect.tryPromise({
87 try: () =>
88 sharp({ create: { width: size, height: size, channels: 4, background } })
89 .png()
90 .toBuffer(),
91 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
92 });
93
76const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* (94const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* (
77 repositoryRoot: string,95 repositoryRoot: string,
78 variant: IconVariant,96 variant: IconVariant,
...@@ -87,29 +105,30 @@ const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* (...@@ -87,29 +105,30 @@ const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* (
87105
88const renderForeground = Effect.fn("androidIcons.renderForeground")(function* (106const renderForeground = Effect.fn("androidIcons.renderForeground")(function* (
89 repositoryRoot: string,107 repositoryRoot: string,
108 size: number,
90) {109) {
91 const text = yield* readLayerSource(repositoryRoot, "prod", "text.svg");110 const text = yield* readLayerSource(repositoryRoot, "prod", "text.svg");
92 const paths = text.match(/<path[^>]*\/>/g) ?? [];111 const paths = text.match(/<path[^>]*\/>/g) ?? [];
93 return yield* rasterize(112 return yield* rasterize(
94 "foreground",113 "foreground",
95 canvasSvg(`<g transform="${wordmarkTransform()}">${paths.join("")}</g>`),114 canvasSvg(size, `<g transform="${wordmarkTransform(size)}">${paths.join("")}</g>`),
96 CANVAS,115 size,
97 );116 );
98});117});
99118
100const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBackground")(119const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBackground")(
101 function* (repositoryRoot: string) {120 function* (repositoryRoot: string, size: number) {
102 // The annotation layer shares the wordmark's coordinate space, so it is scaled and121 // The annotation layer shares the wordmark's coordinate space, so it is scaled and
103 // centered the same way to keep the dimension lines around the letters.122 // centered the same way to keep the dimension lines around the letters.
104 const annotations = yield* readLayerSource(repositoryRoot, "dev", "annotations.svg");123 const annotations = yield* readLayerSource(repositoryRoot, "dev", "annotations.svg");
105 const defs = annotations.match(/<defs>[\s\S]*?<\/defs>/)?.[0] ?? "";124 const defs = annotations.match(/<defs>[\s\S]*?<\/defs>/)?.[0] ?? "";
106 const body = annotations.replace(/^[\s\S]*?<\/defs>/, "").replace(/<\/svg>\s*$/, "");125 const body = annotations.replace(/^[\s\S]*?<\/defs>/, "").replace(/<\/svg>\s*$/, "");
107 const paper = yield* readLayerSource(repositoryRoot, "dev", "background.svg");126 const paper = yield* readLayerSource(repositoryRoot, "dev", "background.svg");
108 const background = yield* rasterize("dev-background", fullBleed(paper), CANVAS);127 const background = yield* rasterize("dev-background", fullBleed(paper), size);
109 const overlay = yield* rasterize(128 const overlay = yield* rasterize(
110 "dev-annotations",129 "dev-annotations",
111 canvasSvg(`${defs}<g transform="${wordmarkTransform()}">${body}</g>`),130 canvasSvg(size, `${defs}<g transform="${wordmarkTransform(size)}">${body}</g>`),
112 CANVAS,131 size,
113 );132 );
114 return yield* composite("dev-background", background, [{ input: overlay }]);133 return yield* composite("dev-background", background, [{ input: overlay }]);
115 },134 },
...@@ -117,6 +136,7 @@ const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBac...@@ -117,6 +136,7 @@ const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBac
117136
118const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground")(function* (137const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground")(function* (
119 repositoryRoot: string,138 repositoryRoot: string,
139 size: number,
120) {140) {
121 // Positions mirror assets/nightly/app-icon.icon/icon.json. The SVG blur filter is141 // Positions mirror assets/nightly/app-icon.icon/icon.json. The SVG blur filter is
122 // dropped because Icon Composer ignores it and it smears at this raster size.142 // dropped because Icon Composer ignores it and it smears at this raster size.
...@@ -128,7 +148,7 @@ const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground"...@@ -128,7 +148,7 @@ const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground"
128 translation: [387.9605131881942, -134.30064713259117],148 translation: [387.9605131881942, -134.30064713259117],
129 },149 },
130 ] as const;150 ] as const;
131 const k = CANVAS / COMPOSER_CANVAS_PT;151 const k = size / COMPOSER_CANVAS_PT;
132 const overlays = yield* Effect.forEach(clouds, (cloud) =>152 const overlays = yield* Effect.forEach(clouds, (cloud) =>
133 Effect.gen(function* () {153 Effect.gen(function* () {
134 const width = Math.round(64 * cloud.scale * k);154 const width = Math.round(64 * cloud.scale * k);
...@@ -144,8 +164,8 @@ const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground"...@@ -144,8 +164,8 @@ const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground"
144 );164 );
145 const x0 = Math.max(0, left);165 const x0 = Math.max(0, left);
146 const y0 = Math.max(0, top);166 const y0 = Math.max(0, top);
147 const x1 = Math.min(CANVAS, left + width);167 const x1 = Math.min(size, left + width);
148 const y1 = Math.min(CANVAS, top + height);168 const y1 = Math.min(size, top + height);
149 const clipped = yield* Effect.tryPromise({169 const clipped = yield* Effect.tryPromise({
150 try: () =>170 try: () =>
151 sharp(png)171 sharp(png)
...@@ -158,18 +178,51 @@ const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground"...@@ -158,18 +178,51 @@ const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground"
158 }),178 }),
159 );179 );
160 const sky = yield* readLayerSource(repositoryRoot, "nightly", "background.svg");180 const sky = yield* readLayerSource(repositoryRoot, "nightly", "background.svg");
161 const background = yield* rasterize("nightly-background", fullBleed(sky), CANVAS);181 const background = yield* rasterize("nightly-background", fullBleed(sky), size);
162 return yield* composite("nightly-background", background, overlays);182 return yield* composite("nightly-background", background, overlays);
163});183});
164184
185const renderBackground = Effect.fn("androidIcons.renderBackground")(function* (
186 repositoryRoot: string,
187 variant: IconVariant,
188 size: number,
189) {
190 switch (variant) {
191 case "dev":
192 return yield* renderDevelopmentBackground(repositoryRoot, size);
193 case "nightly":
194 return yield* renderNightlyBackground(repositoryRoot, size);
195 case "prod":
196 return yield* solidCanvas("prod-background", size, PRODUCTION_BACKGROUND_COLOR);
197 }
198});
199
200const renderSplashIcon = Effect.fn("androidIcons.renderSplashIcon")(function* (
201 repositoryRoot: string,
202 variant: IconVariant,
203) {
204 const background = yield* renderBackground(repositoryRoot, variant, SPLASH_CANVAS);
205 const foreground = yield* renderForeground(repositoryRoot, SPLASH_CANVAS);
206 return yield* composite(`${variant}-splash`, background, [{ input: foreground }]);
207});
208
165const exportAndroidIcons = Effect.gen(function* () {209const exportAndroidIcons = Effect.gen(function* () {
166 const fs = yield* FileSystem.FileSystem;210 const fs = yield* FileSystem.FileSystem;
167 const path = yield* Path.Path;211 const path = yield* Path.Path;
168 const repositoryRoot = path.resolve(import.meta.dirname, "..");212 const repositoryRoot = path.resolve(import.meta.dirname, "..");
169 const outputs = [213 const outputs = [
170 ["android-icon-foreground.png", yield* renderForeground(repositoryRoot)],214 ["android-icon-foreground.png", yield* renderForeground(repositoryRoot, ADAPTIVE_CANVAS)],
171 ["android-icon-background-dev.png", yield* renderDevelopmentBackground(repositoryRoot)],215 [
172 ["android-icon-background-nightly.png", yield* renderNightlyBackground(repositoryRoot)],216 "android-icon-background-dev.png",
217 yield* renderDevelopmentBackground(repositoryRoot, ADAPTIVE_CANVAS),
218 ],
219 [
220 "android-icon-background-nightly.png",
221 yield* renderNightlyBackground(repositoryRoot, ADAPTIVE_CANVAS),
222 ],
223 ["android-splash-icon-dev.png", yield* renderSplashIcon(repositoryRoot, "dev")],
224 ["android-splash-icon-nightly.png", yield* renderSplashIcon(repositoryRoot, "nightly")],
225 ["android-splash-icon-prod.png", yield* renderSplashIcon(repositoryRoot, "prod")],
173 ] as const;226 ] as const;
174 for (const [name, contents] of outputs) {227 for (const [name, contents] of outputs) {
175 yield* fs.writeFile(path.join(repositoryRoot, OUTPUT_DIRECTORY, name), contents);228 yield* fs.writeFile(path.join(repositoryRoot, OUTPUT_DIRECTORY, name), contents);