Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?
const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;

const fromRepoRoot = (relativePath: string) => `../../${relativePath}`;
// Universal exports already contain their own rounded-square silhouette. Using one as an adaptive
// foreground makes Android draw an icon shape inside the launcher's mask.
// Android layers are rendered by scripts/export-android-icons.ts from the Icon Composer sources.
// The wordmark sits inside the adaptive safe zone; the variant artwork is a full-bleed background.
const androidAdaptiveForeground = "./assets/android-icon-foreground.png";

if (
Expand All @@ -37,7 +37,8 @@ const DEVELOPMENT_ASSETS = {
iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject),
splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
androidAdaptiveForeground,
androidAdaptiveBackgroundColor: "#00639B",
androidAdaptiveBackgroundColor: "#347FF8",
androidAdaptiveBackgroundImage: "./assets/android-icon-background-dev.png",
androidMonochromeIcon: "./assets/android-icon-mark.png",
androidNotificationIcon: "./assets/android-notification-icon.png",
androidNotificationColor: "#00639B",
Expand All @@ -49,6 +50,7 @@ const PREVIEW_ASSETS = {
splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
androidAdaptiveForeground,
androidAdaptiveBackgroundColor: "#111533",
androidAdaptiveBackgroundImage: "./assets/android-icon-background-nightly.png",
androidMonochromeIcon: "./assets/android-icon-mark.png",
androidNotificationIcon: "./assets/android-notification-icon.png",
androidNotificationColor: "#7565C7",
Expand All @@ -60,6 +62,7 @@ const RELEASE_ASSETS = {
splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
androidAdaptiveForeground,
androidAdaptiveBackgroundColor: "#000000",
androidAdaptiveBackgroundImage: undefined,
androidMonochromeIcon: "./assets/android-icon-mark.png",
androidNotificationIcon: "./assets/android-notification-icon.png",
androidNotificationColor: "#FFFFFF",
Expand Down Expand Up @@ -230,6 +233,9 @@ const config: ExpoConfig = {
package: variant.androidPackage,
adaptiveIcon: {
backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
...(variant.assets.androidAdaptiveBackgroundImage
? { backgroundImage: variant.assets.androidAdaptiveBackgroundImage }
: {}),
foregroundImage: variant.assets.androidAdaptiveForeground,
monochromeImage: variant.assets.androidMonochromeIcon,
},
Expand Down Expand Up @@ -293,6 +299,9 @@ const config: ExpoConfig = {
shortcut_icon: {
foregroundImage: variant.assets.androidAdaptiveForeground,
backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
...(variant.assets.androidAdaptiveBackgroundImage
? { backgroundImage: variant.assets.androidAdaptiveBackgroundImage }
: {}),
},
},
},
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/mobile/assets/android-icon-foreground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 0 additions & 8 deletions apps/mobile/assets/android-icon-foreground.svg

This file was deleted.

21 changes: 11 additions & 10 deletions assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,17 @@ Verify every result is 1024×1024 and has the classic macOS safe area: an 824×8

Do not edit the generated PNG or ICO files directly.

## Android adaptive foreground
## Android adaptive icon layers

`apps/mobile/assets/android-icon-foreground.svg` is the source of truth for the foreground used by
the normal Android adaptive launcher icon. Export its paired PNG after changing it:
Android masks the central 72dp of a 108dp adaptive canvas, so the Icon Composer exports cannot be
used directly: their rounded-square silhouette gets framed again and the wordmark is cropped. The
Android layers are instead rendered from the same Icon Composer SVG sources by
`vp run icons:export:android`:

```sh
rsvg-convert -w 432 -h 432 \
-o apps/mobile/assets/android-icon-foreground.png \
apps/mobile/assets/android-icon-foreground.svg
```
- `apps/mobile/assets/android-icon-foreground.png`: the shared transparent wordmark, sized to stay
inside the safe zone
- `apps/mobile/assets/android-icon-background-dev.png` and `-nightly.png`: full-bleed variant
artwork (blueprint grid and annotations; night sky and clouds). Production uses a solid color.

The foreground must remain transparent and keep the T3 mark inside Android's adaptive-icon safe
zone. `android-icon-mark.png` remains a flat silhouette for Android's monochrome themed icon.
Rerun the export after changing a layer SVG. `android-icon-mark.png` remains a flat silhouette for
Android's monochrome themed icon.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"screenshots:mobile": "node scripts/mobile-showcase.ts",
"icons:export": "node scripts/export-brand-icons.ts",
"icons:check": "node scripts/export-brand-icons.ts --check",
"icons:export:android": "node scripts/export-android-icons.ts",
"build": "vp run --filter './apps/*' build",
"build:marketing": "vp run --filter @t3tools/marketing build",
"build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

182 changes: 182 additions & 0 deletions scripts/export-android-icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env node

// Renders the Android adaptive-icon layers from the Icon Composer SVG sources.
//
// Icon Composer exports already contain a rounded-square silhouette, and Android masks
// the central 72dp of a 108dp adaptive canvas, so exporting them as a foreground produces
// a double-framed icon with the letters cropped by the mask. Instead, each variant gets a
// full-bleed background layer (the artwork behind the wordmark) and a shared transparent
// foreground that keeps the wordmark inside the safe zone.

import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import sharp from "sharp";

type IconVariant = "dev" | "nightly" | "prod";

// 108dp at xxxhdpi. Expo's prebuild derives every density bucket from this.
const CANVAS = 432;
// Icon Composer's layer sources use a 128pt viewBox; the wordmark path spans this box.
const TEXT = { x: 15.53, y: 37, width: 94.5, height: 57 };
// Wordmark width as a fraction of the 108dp canvas. The visible area is 72dp (66dp
// guaranteed), so 0.48 leaves the letters at ~72% of the mask with room for the
// launcher's own zoom effects.
const WORDMARK_FRACTION = 0.48;
// Icon Composer positions layers on a 1024pt canvas, with translation relative to center.
const COMPOSER_CANVAS_PT = 1024;
const SVG_DENSITY = 300;
const OUTPUT_DIRECTORY = "apps/mobile/assets";

export class AndroidIconRenderError extends Schema.TaggedErrorClass<AndroidIconRenderError>()(
"AndroidIconRenderError",
{ layer: Schema.String, cause: Schema.Defect() },
) {}

const wordmarkTransform = () => {
const scale = (CANVAS * WORDMARK_FRACTION) / TEXT.width;
const tx = (CANVAS - TEXT.width * scale) / 2 - TEXT.x * scale;
const ty = (CANVAS - TEXT.height * scale) / 2 - TEXT.y * scale;
return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${scale.toFixed(4)})`;
};

const canvasSvg = (inner: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS}" height="${CANVAS}" viewBox="0 0 ${CANVAS} ${CANVAS}" fill="none">${inner}</svg>`;

// The layer sources clip to a 10pt rounded rectangle for the iOS silhouette. Android
// applies its own mask, so the layer must bleed to the canvas edge.
const fullBleed = (svg: string) =>
svg.replace(/<rect width="128" height="128" rx="10"\/>/, '<rect width="128" height="128"/>');

const rasterize = (layer: string, svg: string, width: number, height = width) =>
Effect.tryPromise({
try: () =>
sharp(Buffer.from(svg), { density: SVG_DENSITY }).resize(width, height).png().toBuffer(),
catch: (cause) => new AndroidIconRenderError({ layer, cause }),
});

const composite = (
layer: string,
base: Buffer,
overlays: ReadonlyArray<{ input: Buffer; left?: number; top?: number }>,
) =>
Effect.tryPromise({
try: () =>
sharp(base)
.composite([...overlays])
.png()
.toBuffer(),
catch: (cause) => new AndroidIconRenderError({ layer, cause }),
});

const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* (
repositoryRoot: string,
variant: IconVariant,
file: string,
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
return yield* fs.readFileString(
path.join(repositoryRoot, "assets", variant, "app-icon.icon", "Assets", file),
);
});

const renderForeground = Effect.fn("androidIcons.renderForeground")(function* (
repositoryRoot: string,
) {
const text = yield* readLayerSource(repositoryRoot, "prod", "text.svg");
const paths = text.match(/<path[^>]*\/>/g) ?? [];
return yield* rasterize(
"foreground",
canvasSvg(`<g transform="${wordmarkTransform()}">${paths.join("")}</g>`),
CANVAS,
);
});

const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBackground")(
function* (repositoryRoot: string) {
// The annotation layer shares the wordmark's coordinate space, so it is scaled and
// centered the same way to keep the dimension lines around the letters.
const annotations = yield* readLayerSource(repositoryRoot, "dev", "annotations.svg");
const defs = annotations.match(/<defs>[\s\S]*?<\/defs>/)?.[0] ?? "";
const body = annotations.replace(/^[\s\S]*?<\/defs>/, "").replace(/<\/svg>\s*$/, "");
const paper = yield* readLayerSource(repositoryRoot, "dev", "background.svg");
const background = yield* rasterize("dev-background", fullBleed(paper), CANVAS);
const overlay = yield* rasterize(
"dev-annotations",
canvasSvg(`${defs}<g transform="${wordmarkTransform()}">${body}</g>`),
CANVAS,
);
return yield* composite("dev-background", background, [{ input: overlay }]);
},
);

const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground")(function* (
repositoryRoot: string,
) {
// Positions mirror assets/nightly/app-icon.icon/icon.json. The SVG blur filter is
// dropped because Icon Composer ignores it and it smears at this raster size.
const clouds = [
{ file: "cloud-lower-left.svg", scale: 25, translation: [-309.6375, 268.66077693836917] },
{
file: "cloud-upper-right.svg",
scale: 15,
translation: [387.9605131881942, -134.30064713259117],
},
] as const;
const k = CANVAS / COMPOSER_CANVAS_PT;
const overlays = yield* Effect.forEach(clouds, (cloud) =>
Effect.gen(function* () {
const width = Math.round(64 * cloud.scale * k);
const height = Math.round(32 * cloud.scale * k);
const left = Math.round((COMPOSER_CANVAS_PT / 2 + cloud.translation[0]) * k - width / 2);
const top = Math.round((COMPOSER_CANVAS_PT / 2 + cloud.translation[1]) * k - height / 2);
const source = yield* readLayerSource(repositoryRoot, "nightly", cloud.file);
const png = yield* rasterize(
cloud.file,
source.replace(/ filter="url\(#soft\)"/, ""),
width,
height,
);
const x0 = Math.max(0, left);
const y0 = Math.max(0, top);
const x1 = Math.min(CANVAS, left + width);
const y1 = Math.min(CANVAS, top + height);
const clipped = yield* Effect.tryPromise({
try: () =>
sharp(png)
.extract({ left: x0 - left, top: y0 - top, width: x1 - x0, height: y1 - y0 })
.png()
.toBuffer(),
catch: (cause) => new AndroidIconRenderError({ layer: cloud.file, cause }),
});
return { input: clipped, left: x0, top: y0 };
}),
);
const sky = yield* readLayerSource(repositoryRoot, "nightly", "background.svg");
const background = yield* rasterize("nightly-background", fullBleed(sky), CANVAS);
return yield* composite("nightly-background", background, overlays);
});

const exportAndroidIcons = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const repositoryRoot = path.resolve(import.meta.dirname, "..");
const outputs = [
["android-icon-foreground.png", yield* renderForeground(repositoryRoot)],
["android-icon-background-dev.png", yield* renderDevelopmentBackground(repositoryRoot)],
["android-icon-background-nightly.png", yield* renderNightlyBackground(repositoryRoot)],
] as const;
for (const [name, contents] of outputs) {
yield* fs.writeFile(path.join(repositoryRoot, OUTPUT_DIRECTORY, name), contents);
yield* Console.log(`wrote ${OUTPUT_DIRECTORY}/${name}`);
}
});

if (import.meta.main) {
exportAndroidIcons.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
}
3 changes: 2 additions & 1 deletion scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"@t3tools/shared": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"effect": "catalog:",
"pngjs": "7.0.0"
"pngjs": "7.0.0",
"sharp": "0.34.5"
},
"devDependencies": {
"@effect/vitest": "catalog:",
Expand Down
Loading