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>
This commit is contained in:
2026-06-11 21:20:01 +02:00
parent bdea95511d
commit a01abbb1bf
31 changed files with 89 additions and 14 deletions

View File

@@ -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:

View File

@@ -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);
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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);
});
});

View File

@@ -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',

View File

@@ -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;