#!/usr/bin/env node // Regenerates the Android launcher icons (adaptive + legacy) and splash screens // for the Capacitor shell from the brand icon. Run with `npm run cap:assets:android`. // // Source of truth: images/icon-new-rounded.png (circular brand mark on a purple disc). // Output: toju-app/android/app/src/main/res/{mipmap-*,drawable*,values}. // // The brand background colour and required output set are mirrored by // toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts, // which the Vitest suite asserts against. import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const SOURCE_ICON = resolve(REPO_ROOT, 'images/icon-new-rounded.png'); const RES_DIR = resolve(REPO_ROOT, 'toju-app/android/app/src/main/res'); /** Brand purple sampled from the brand icon disc. Keep in sync with the rules file. */ const BRAND_BACKGROUND = { r: 74, g: 33, b: 122 }; const BRAND_BACKGROUND_HEX = '#4A217A'; /** Adaptive-icon safe zone (66dp inside 108dp). Keep in sync with the rules file. */ const ADAPTIVE_FOREGROUND_ICON_RATIO = 66 / 108; const LEGACY_LAUNCHER_ICON_RATIO = ADAPTIVE_FOREGROUND_ICON_RATIO; const SPLASH_ICON_RATIO = 0.32; /** Legacy square/round launcher bitmap edge length per density. */ const LEGACY_ICON_PX = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 }; /** Adaptive foreground canvas edge length per density (108dp). */ const FOREGROUND_PX = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 }; /** Portrait splash dimensions per density; landscape swaps width/height. */ const SPLASH_PORTRAIT = { mdpi: [320, 480], hdpi: [480, 800], xhdpi: [720, 1280], xxhdpi: [960, 1600], xxxhdpi: [1280, 1920] }; async function ensureDir(filePath) { await mkdir(dirname(filePath), { recursive: true }); } async function writePng(buffer, resRelativePath) { const outPath = resolve(RES_DIR, resRelativePath); await ensureDir(outPath); await writeFile(outPath, buffer); return resRelativePath; } /** Resize the circular brand icon to a square of `size` px, preserving transparent corners. */ async function squareIcon(size) { return sharp(SOURCE_ICON).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer(); } /** Compose the brand icon centred at `iconRatio` of the canvas over a solid background. */ async function centeredIconOnBackground(width, height, iconRatio, background) { const iconSize = Math.round(Math.min(width, height) * iconRatio); const icon = await squareIcon(iconSize); return sharp({ create: { width, height, channels: 4, background } }) .composite([{ input: icon, gravity: 'center' }]) .png() .toBuffer(); } /** Compose the brand icon centred on a transparent canvas for adaptive foreground layers. */ async function centeredIconOnTransparentCanvas(canvasSize, iconRatio) { const iconSize = Math.round(canvasSize * iconRatio); const icon = await squareIcon(iconSize); return sharp({ create: { width: canvasSize, height: canvasSize, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }) .composite([{ input: icon, gravity: 'center' }]) .png() .toBuffer(); } async function generateLauncherIcons() { const written = []; for (const [density, size] of Object.entries(LEGACY_ICON_PX)) { const background = { ...BRAND_BACKGROUND, alpha: 1 }; const bitmap = await centeredIconOnBackground(size, size, LEGACY_LAUNCHER_ICON_RATIO, background); written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher.png`)); written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher_round.png`)); } for (const [density, size] of Object.entries(FOREGROUND_PX)) { const foreground = await centeredIconOnTransparentCanvas(size, ADAPTIVE_FOREGROUND_ICON_RATIO); written.push(await writePng(foreground, `mipmap-${density}/ic_launcher_foreground.png`)); } return written; } async function generateAdaptiveBackgroundColor() { const xml = `\n\n ${BRAND_BACKGROUND_HEX}\n\n`; const outPath = resolve(RES_DIR, 'values/ic_launcher_background.xml'); await writeFile(outPath, xml); return 'values/ic_launcher_background.xml'; } async function generateSplashScreens() { const written = []; const background = { ...BRAND_BACKGROUND, alpha: 1 }; for (const [density, [portW, portH]] of Object.entries(SPLASH_PORTRAIT)) { const portrait = await centeredIconOnBackground(portW, portH, SPLASH_ICON_RATIO, background); const landscape = await centeredIconOnBackground(portH, portW, SPLASH_ICON_RATIO, background); written.push(await writePng(portrait, `drawable-port-${density}/splash.png`)); written.push(await writePng(landscape, `drawable-land-${density}/splash.png`)); } const base = await centeredIconOnBackground(480, 320, SPLASH_ICON_RATIO, background); written.push(await writePng(base, 'drawable/splash.png')); return written; } async function main() { const written = [ ...(await generateLauncherIcons()), await generateAdaptiveBackgroundColor(), ...(await generateSplashScreens()) ]; console.log(`Generated ${written.length} Android brand resources from ${SOURCE_ICON}:`); for (const file of written) { console.log(` res/${file}`); } } main().catch((error) => { console.error(error); process.exitCode = 1; });