Scale launcher and splash brand assets to the adaptive-icon safe zone and a smaller splash ratio so circular launcher masks and the launch splash no longer crop the cat face. Co-authored-by: Cursor <cursoragent@cursor.com>
150 lines
5.5 KiB
JavaScript
150 lines
5.5 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';
|
|
|
|
/** 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 = `<?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, 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;
|
|
});
|