fix: Bug - Android app is zoomed in
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>
@@ -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:
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 12 KiB |
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||