diff --git a/agents-docs/features/mobile-capacitor.md b/agents-docs/features/mobile-capacitor.md index 95bfedb..4092ffe 100644 --- a/agents-docs/features/mobile-capacitor.md +++ b/agents-docs/features/mobile-capacitor.md @@ -68,10 +68,10 @@ npm run cap:assets:android # → tools/generate-android-app-icons.mjs (uses sh This produces, for every density (`mdpi … xxxhdpi`): -- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc). -- `mipmap-*/ic_launcher_foreground.png` — full-bleed adaptive foreground; the adaptive layers in `mipmap-anydpi-v26/ic_launcher*.xml` reference `@mipmap/ic_launcher_foreground` (the stock `drawable-v24/ic_launcher_foreground.xml` vector is removed). +- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc inset to the adaptive-icon safe zone so circular masks do not clip the cat face). +- `mipmap-*/ic_launcher_foreground.png` — adaptive foreground centred at **66/108** of the 108dp canvas (Android safe zone); the adaptive layers in `mipmap-anydpi-v26/ic_launcher*.xml` reference `@mipmap/ic_launcher_foreground` with `@color/ic_launcher_background` brand purple behind it. - `values/ic_launcher_background.xml` — adaptive background colour set to the **brand purple `#4A217A`**, not stock white. -- `drawable*/splash.png` (port + land per density, plus the base) — brand mark centred on a purple field for the launch splash. +- `drawable*/splash.png` (port + land per density, plus the base) — brand mark centred at **32%** of the shorter splash edge on a purple field (down from 40% so the cat face is not cropped on launch). Invariants are encoded in `toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts` (required file set, brand background colour, and the SHA-256 of every stock Capacitor placeholder that must never reappear). Coverage: diff --git a/e2e/tests/mobile/android-app-icon.spec.ts b/e2e/tests/mobile/android-app-icon.spec.ts index 8069626..55a6fe2 100644 --- a/e2e/tests/mobile/android-app-icon.spec.ts +++ b/e2e/tests/mobile/android-app-icon.spec.ts @@ -6,13 +6,15 @@ import sharp from 'sharp'; import { test, expect } from '../../fixtures/base'; import { + ADAPTIVE_FOREGROUND_ICON_RATIO, BRAND_LAUNCHER_BACKGROUND_COLOR, findMissingLauncherResources, findStockCapacitorResources, isBrandLauncherBackgroundColor, readAdaptiveIconBackgroundColor, REQUIRED_LAUNCHER_ICON_FILES, - REQUIRED_SPLASH_FILES + REQUIRED_SPLASH_FILES, + SPLASH_ICON_RATIO } from '../../../toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules'; /** @@ -104,4 +106,16 @@ test.describe('Android brand app icon', () => { expect(colorDistance(corner, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE); expect(colorDistance(center, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE); }); + + test('insets the adaptive foreground so launcher masks do not clip the cat face', async () => { + const foreground = 'mipmap-xxxhdpi/ic_launcher_foreground.png'; + const { data, info } = await sharp(join(RES_DIR, foreground)).ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const topCenterOffset = (0 * info.width + Math.floor(info.width / 2)) * info.channels; + + expect(data[topCenterOffset + 3]).toBeLessThan(32); + expect(ADAPTIVE_FOREGROUND_ICON_RATIO).toBeCloseTo(66 / 108, 5); + expect(SPLASH_ICON_RATIO).toBeLessThan(0.4); + }); }); diff --git a/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png index af67300..f12e7bd 100644 Binary files a/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png index 1a74adf..2cf11c6 100644 Binary files a/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png index cfe4e4f..a145a18 100644 Binary files a/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png index d9103c5..f41100b 100644 Binary files a/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png index e8f13f8..19a97a3 100644 Binary files a/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png index ef760b7..b5af97f 100644 Binary files a/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png index 7b972f0..0f6d504 100644 Binary files a/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png index 0b620fe..5d82942 100644 Binary files a/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png index c66516e..f88c916 100644 Binary files a/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png index 989a7ed..2376f8c 100644 Binary files a/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/toju-app/android/app/src/main/res/drawable/splash.png b/toju-app/android/app/src/main/res/drawable/splash.png index 1a74adf..2cf11c6 100644 Binary files a/toju-app/android/app/src/main/res/drawable/splash.png and b/toju-app/android/app/src/main/res/drawable/splash.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 0a38f55..75895d9 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index a2da718..18560eb 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 0a38f55..75895d9 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 087fb6d..ec1b971 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 633f167..aa0767f 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 087fb6d..ec1b971 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 357acc4..a417dab 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 23d408a..7cb07f7 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 357acc4..a417dab 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 96603ef..f6dee15 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 6d3c95b..304314c 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 96603ef..f6dee15 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 985a773..52f3126 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 75eabf3..3cf42d9 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 985a773..52f3126 100644 Binary files a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.spec.ts index 9a82952..2ab2dab 100644 --- a/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.spec.ts +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.spec.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import sharp from 'sharp'; import { describe, expect, @@ -9,13 +10,16 @@ import { } from 'vitest'; import { + ADAPTIVE_FOREGROUND_ICON_RATIO, BRAND_LAUNCHER_BACKGROUND_COLOR, findMissingLauncherResources, findStockCapacitorResources, isBrandLauncherBackgroundColor, readAdaptiveIconBackgroundColor, REQUIRED_LAUNCHER_ICON_FILES, - REQUIRED_SPLASH_FILES + REQUIRED_SPLASH_FILES, + resolveIconPixelSize, + SPLASH_ICON_RATIO } from './mobile-android-launcher-icon.rules'; const RES_DIR = resolve(process.cwd(), 'android/app/src/main/res'); @@ -31,6 +35,12 @@ describe('mobile-android-launcher-icon.rules', () => { const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES]; const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file))); + it('keeps the brand mark inside the adaptive-icon safe zone', () => { + expect(ADAPTIVE_FOREGROUND_ICON_RATIO).toBeCloseTo(66 / 108, 5); + expect(resolveIconPixelSize(432)).toBe(264); + expect(SPLASH_ICON_RATIO).toBeLessThan(0.4); + }); + it('ships a launcher icon and splash for every required density', () => { expect(findMissingLauncherResources(presentFiles)).toEqual([]); }); @@ -49,4 +59,15 @@ describe('mobile-android-launcher-icon.rules', () => { expect(isBrandLauncherBackgroundColor(color)).toBe(true); expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase()); }); + + it('insets the adaptive foreground so launcher masks do not clip the cat face', async () => { + const foregroundPath = resolve(RES_DIR, 'mipmap-xxxhdpi/ic_launcher_foreground.png'); + const { data, info } = await sharp(foregroundPath).ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const topCenterOffset = (0 * info.width + Math.floor(info.width / 2)) * info.channels; + const topCenterAlpha = data[topCenterOffset + 3]; + + expect(topCenterAlpha).toBeLessThan(32); + }); }); diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts index 76e0c95..9e0533e 100644 --- a/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts +++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts @@ -8,6 +8,26 @@ /** Brand purple sampled from `images/icon-new-rounded.png` (the circle behind the cat). */ export const BRAND_LAUNCHER_BACKGROUND_COLOR = '#4A217A'; +/** Adaptive-icon foreground canvas size in dp (Android spec). */ +export const ADAPTIVE_ICON_CANVAS_DP = 108; + +/** Visible safe-zone diameter inside the adaptive-icon foreground layer (Android spec). */ +export const ADAPTIVE_ICON_SAFE_ZONE_DP = 66; + +/** Scale the brand mark to fit inside the adaptive-icon safe zone instead of full-bleed. */ +export const ADAPTIVE_FOREGROUND_ICON_RATIO = ADAPTIVE_ICON_SAFE_ZONE_DP / ADAPTIVE_ICON_CANVAS_DP; + +/** Legacy square/round launcher bitmaps use the same inset so circular masks do not clip the cat. */ +export const LEGACY_LAUNCHER_ICON_RATIO = ADAPTIVE_FOREGROUND_ICON_RATIO; + +/** Splash art keeps the brand mark smaller than the screen so ears and cheeks stay visible. */ +export const SPLASH_ICON_RATIO = 0.32; + +/** Return the brand icon pixel size for a square canvas at the given scale ratio. */ +export function resolveIconPixelSize(canvasPx: number, ratio: number = ADAPTIVE_FOREGROUND_ICON_RATIO): number { + return Math.round(canvasPx * ratio); +} + /** Density buckets Android resolves launcher icons from. */ export const ANDROID_ICON_DENSITIES = [ 'mdpi', diff --git a/tools/generate-android-app-icons.mjs b/tools/generate-android-app-icons.mjs index d3f8fb3..89a79a0 100644 --- a/tools/generate-android-app-icons.mjs +++ b/tools/generate-android-app-icons.mjs @@ -23,6 +23,11 @@ const RES_DIR = resolve(REPO_ROOT, 'toju-app/android/app/src/main/res'); 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 }; @@ -65,20 +70,36 @@ async function centeredIconOnBackground(width, height, iconRatio, background) { .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 bitmap = await squareIcon(size); + 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`)); } - // 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); + const foreground = await centeredIconOnTransparentCanvas(size, ADAPTIVE_FOREGROUND_ICON_RATIO); written.push(await writePng(foreground, `mipmap-${density}/ic_launcher_foreground.png`)); } @@ -97,14 +118,13 @@ async function generateSplashScreens() { 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); + 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`)); } - // Base fallback splash mirrors the landscape mdpi geometry (Capacitor default). - const base = await centeredIconOnBackground(480, 320, 0.4, background); + const base = await centeredIconOnBackground(480, 320, SPLASH_ICON_RATIO, background); written.push(await writePng(base, 'drawable/splash.png')); return written;