History

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

Julius Marminge committed 2 months ago

Commit 892de471 parent d081ab76 files · +91 −24Browse files

Unified · Split

6 files
  1. apps/mobile/app.config.ts +12 −0
  2. apps/mobile/assets/android-splash-icon-dev.png +0 −0
  3. apps/mobile/assets/android-splash-icon-nightly.png +0 −0
  4. apps/mobile/assets/android-splash-icon-prod.png +0 −0
  5. assets/README.md +7 −5
  6. scripts/export-android-icons.ts +75 −22
apps/mobile/app.config.ts+12−0
@@ -39,6 +39,7 @@ const DEVELOPMENT_ASSETS = { · 38 unmodified lines
3939 androidAdaptiveForeground,
4040 androidAdaptiveBackgroundColor: "#347FF8",
4141 androidAdaptiveBackgroundImage: "./assets/android-icon-background-dev.png",
42+ androidSplashIcon: "./assets/android-splash-icon-dev.png",
4243 androidMonochromeIcon: "./assets/android-icon-mark.png",
4344 androidNotificationIcon: "./assets/android-notification-icon.png",
4445 androidNotificationColor: "#00639B",
@@ -51,6 +52,7 @@ const PREVIEW_ASSETS = { · 6 unmodified lines
5152 androidAdaptiveForeground,
5253 androidAdaptiveBackgroundColor: "#111533",
5354 androidAdaptiveBackgroundImage: "./assets/android-icon-background-nightly.png",
55+ androidSplashIcon: "./assets/android-splash-icon-nightly.png",
5456 androidMonochromeIcon: "./assets/android-icon-mark.png",
5557 androidNotificationIcon: "./assets/android-notification-icon.png",
5658 androidNotificationColor: "#7565C7",
@@ -63,6 +65,7 @@ const RELEASE_ASSETS = { · 6 unmodified lines
6365 androidAdaptiveForeground,
6466 androidAdaptiveBackgroundColor: "#000000",
6567 androidAdaptiveBackgroundImage: undefined,
68+ androidSplashIcon: "./assets/android-splash-icon-prod.png",
6669 androidMonochromeIcon: "./assets/android-icon-mark.png",
6770 androidNotificationIcon: "./assets/android-notification-icon.png",
6871 androidNotificationColor: "#FFFFFF",
@@ -339,6 +342,15 @@ const config: ExpoConfig = { · 270 unmodified lines
339342 image: variant.assets.splashIcon,
340343 backgroundColor: "#0a0a0a",
341344 },
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+ },
342354 },
343355 ],
344356 [
apps/mobile/assets/android-splash-icon-dev.png+0−0

Binary file not shown.

apps/mobile/assets/android-splash-icon-nightly.png+0−0

Binary file not shown.

apps/mobile/assets/android-splash-icon-prod.png+0−0

Binary file not shown.

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 · 48 unmodified lines
4949
5050 Do not edit the generated PNG or ICO files directly.
5151
52−## Android adaptive icon layers
52+## Android launcher and splash artwork
5353
54−Android masks the central 72dp of a 108dp adaptive canvas, so the Icon Composer exports cannot be
55−used directly: their rounded-square silhouette gets framed again and the wordmark is cropped. The
56−Android layers are instead rendered from the same Icon Composer SVG sources by
57−`vp run icons:export:android`:
54+Android masks the central 72dp of a 108dp adaptive canvas, and the Android 12+ splash screen masks
55+the central two thirds of a 288dp canvas, so the Icon Composer exports cannot be used directly:
56+their rounded-square silhouette gets framed again and the wordmark is cropped. The Android artwork
57+is instead rendered from the same Icon Composer SVG sources by `vp run icons:export:android`:
5858
5959 - `apps/mobile/assets/android-icon-foreground.png`: the shared transparent wordmark, sized to stay
6060 inside the safe zone
6161 - `apps/mobile/assets/android-icon-background-dev.png` and `-nightly.png`: full-bleed variant
6262 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
6466 Rerun the export after changing a layer SVG. `android-icon-mark.png` remains a flat silhouette for
6567 Android's monochrome themed icon.
scripts/export-android-icons.ts+75−22
@@ -1,12 +1,17 @@
11 #!/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.
44 //
55 // Icon Composer exports already contain a rounded-square silhouette, and Android masks
66 // the central 72dp of a 108dp adaptive canvas, so exporting them as a foreground produces
77 // a double-framed icon with the letters cropped by the mask. Instead, each variant gets a
88 // full-bleed background layer (the artwork behind the wordmark) and a shared transparent
99 // 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
1116 import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
1217 import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -19,8 +24,10 @@ import sharp from "sharp"; · 6 unmodified lines
1924
2025 type IconVariant = "dev" | "nightly" | "prod";
2126
22−// 108dp at xxxhdpi. Expo's prebuild derives every density bucket from this.
23−const CANVAS = 432;
27+// 108dp at xxxhdpi. Expo's prebuild derives every launcher density bucket from this.
28+const ADAPTIVE_CANVAS = 432;
29+// 288dp at xxxhdpi: the full Android 12+ splash canvas, so the icon needs no upscaling.
30+const SPLASH_CANVAS = 1152;
2431 // Icon Composer's layer sources use a 128pt viewBox; the wordmark path spans this box.
2532 const TEXT = { x: 15.53, y: 37, width: 94.5, height: 57 };
2633 // Wordmark width as a fraction of the 108dp canvas. The visible area is 72dp (66dp
@@ -31,21 +38,23 @@ const WORDMARK_FRACTION = 0.48; · 4 unmodified lines
3138 const COMPOSER_CANVAS_PT = 1024;
3239 const SVG_DENSITY = 300;
3340 const OUTPUT_DIRECTORY = "apps/mobile/assets";
41+// Production has no background artwork, so its splash composes onto the adaptive color.
42+const PRODUCTION_BACKGROUND_COLOR = "#000000";
3443
3544 export class AndroidIconRenderError extends Schema.TaggedErrorClass<AndroidIconRenderError>()(
3645 "AndroidIconRenderError",
3746 { layer: Schema.String, cause: Schema.Defect() },
3847 ) {}
3948
40−const wordmarkTransform = () => {
41− const scale = (CANVAS * WORDMARK_FRACTION) / TEXT.width;
42− const tx = (CANVAS - TEXT.width * scale) / 2 - TEXT.x * scale;
43− const ty = (CANVAS - TEXT.height * scale) / 2 - TEXT.y * scale;
49+const wordmarkTransform = (size: number) => {
50+ const scale = (size * WORDMARK_FRACTION) / TEXT.width;
51+ const tx = (size - TEXT.width * scale) / 2 - TEXT.x * scale;
52+ const ty = (size - TEXT.height * scale) / 2 - TEXT.y * scale;
4453 return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${scale.toFixed(4)})`;
4554 };
4655
47−const canvasSvg = (inner: string) =>
48− `<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS}" height="${CANVAS}" viewBox="0 0 ${CANVAS} ${CANVAS}" fill="none">${inner}</svg>`;
56+const canvasSvg = (size: number, inner: string) =>
57+ `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none">${inner}</svg>`;
4958
5059 // The layer sources clip to a 10pt rounded rectangle for the iOS silhouette. Android
5160 // applies its own mask, so the layer must bleed to the canvas edge.
@@ -73,6 +82,15 @@ const composite = ( · 21 unmodified lines
7382 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
7483 });
7584
85+const 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+
7694 const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* (
7795 repositoryRoot: string,
7896 variant: IconVariant,
@@ -87,29 +105,30 @@ const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* ( · 8 unmodified lines
87105
88106 const renderForeground = Effect.fn("androidIcons.renderForeground")(function* (
89107 repositoryRoot: string,
108+ size: number,
90109 ) {
91110 const text = yield* readLayerSource(repositoryRoot, "prod", "text.svg");
92111 const paths = text.match(/<path[^>]*\/>/g) ?? [];
93112 return yield* rasterize(
94113 "foreground",
95− canvasSvg(`<g transform="${wordmarkTransform()}">${paths.join("")}</g>`),
96− CANVAS,
114+ canvasSvg(size, `<g transform="${wordmarkTransform(size)}">${paths.join("")}</g>`),
115+ size,
97116 );
98117 });
99118
100119 const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBackground")(
101− function* (repositoryRoot: string) {
120+ function* (repositoryRoot: string, size: number) {
102121 // The annotation layer shares the wordmark's coordinate space, so it is scaled and
103122 // centered the same way to keep the dimension lines around the letters.
104123 const annotations = yield* readLayerSource(repositoryRoot, "dev", "annotations.svg");
105124 const defs = annotations.match(/<defs>[\s\S]*?<\/defs>/)?.[0] ?? "";
106125 const body = annotations.replace(/^[\s\S]*?<\/defs>/, "").replace(/<\/svg>\s*$/, "");
107126 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);
109128 const overlay = yield* rasterize(
110129 "dev-annotations",
111− canvasSvg(`${defs}<g transform="${wordmarkTransform()}">${body}</g>`),
112− CANVAS,
130+ canvasSvg(size, `${defs}<g transform="${wordmarkTransform(size)}">${body}</g>`),
131+ size,
113132 );
114133 return yield* composite("dev-background", background, [{ input: overlay }]);
115134 },
@@ -117,6 +136,7 @@ const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBac · 1 unmodified line
117136
118137 const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground")(function* (
119138 repositoryRoot: string,
139+ size: number,
120140 ) {
121141 // Positions mirror assets/nightly/app-icon.icon/icon.json. The SVG blur filter is
122142 // dropped because Icon Composer ignores it and it smears at this raster size.
@@ -128,7 +148,7 @@ const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground" · 5 unmodified lines
128148 translation: [387.9605131881942, -134.30064713259117],
129149 },
130150 ] as const;
131− const k = CANVAS / COMPOSER_CANVAS_PT;
151+ const k = size / COMPOSER_CANVAS_PT;
132152 const overlays = yield* Effect.forEach(clouds, (cloud) =>
133153 Effect.gen(function* () {
134154 const width = Math.round(64 * cloud.scale * k);
@@ -144,8 +164,8 @@ const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground" · 9 unmodified lines
144164 );
145165 const x0 = Math.max(0, left);
146166 const y0 = Math.max(0, top);
147− const x1 = Math.min(CANVAS, left + width);
148− const y1 = Math.min(CANVAS, top + height);
167+ const x1 = Math.min(size, left + width);
168+ const y1 = Math.min(size, top + height);
149169 const clipped = yield* Effect.tryPromise({
150170 try: () =>
151171 sharp(png)
@@ -158,18 +178,51 @@ const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground" · 6 unmodified lines
158178 }),
159179 );
160180 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);
162182 return yield* composite("nightly-background", background, overlays);
163183 });
164184
185+const 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+
200+const 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+
165209 const exportAndroidIcons = Effect.gen(function* () {
166210 const fs = yield* FileSystem.FileSystem;
167211 const path = yield* Path.Path;
168212 const repositoryRoot = path.resolve(import.meta.dirname, "..");
169213 const outputs = [
170− ["android-icon-foreground.png", yield* renderForeground(repositoryRoot)],
171− ["android-icon-background-dev.png", yield* renderDevelopmentBackground(repositoryRoot)],
172− ["android-icon-background-nightly.png", yield* renderNightlyBackground(repositoryRoot)],
214+ ["android-icon-foreground.png", yield* renderForeground(repositoryRoot, ADAPTIVE_CANVAS)],
215+ [
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")],
173226 ] as const;
174227 for (const [name, contents] of outputs) {
175228 yield* fs.writeFile(path.join(repositoryRoot, OUTPUT_DIRECTORY, name), contents);