Files
Toju/tools/generate-android-app-icons.mjs
2026-06-11 03:03:10 +02:00

130 lines
4.9 KiB
JavaScript

#!/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';
/** 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();
}
async function generateLauncherIcons() {
const written = [];
for (const [density, size] of Object.entries(LEGACY_ICON_PX)) {
const bitmap = await squareIcon(size);
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher.png`));
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher_round.png`));
}
// Adaptive foreground: full-bleed circle on transparent canvas. The adaptive
// background layer paints the brand purple, so the masked result matches the
// source disc on every launcher mask shape.
for (const [density, size] of Object.entries(FOREGROUND_PX)) {
const foreground = await squareIcon(size);
written.push(await writePng(foreground, `mipmap-${density}/ic_launcher_foreground.png`));
}
return written;
}
async function generateAdaptiveBackgroundColor() {
const xml = `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${BRAND_BACKGROUND_HEX}</color>\n</resources>\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, 0.4, background);
const landscape = await centeredIconOnBackground(portH, portW, 0.4, background);
written.push(await writePng(portrait, `drawable-port-${density}/splash.png`));
written.push(await writePng(landscape, `drawable-land-${density}/splash.png`));
}
// Base fallback splash mirrors the landscape mdpi geometry (Capacitor default).
const base = await centeredIconOnBackground(480, 320, 0.4, 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;
});