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`):
|
This produces, for every density (`mdpi … xxxhdpi`):
|
||||||
|
|
||||||
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc).
|
- `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` — 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_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.
|
- `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:
|
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 { test, expect } from '../../fixtures/base';
|
||||||
import {
|
import {
|
||||||
|
ADAPTIVE_FOREGROUND_ICON_RATIO,
|
||||||
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||||
findMissingLauncherResources,
|
findMissingLauncherResources,
|
||||||
findStockCapacitorResources,
|
findStockCapacitorResources,
|
||||||
isBrandLauncherBackgroundColor,
|
isBrandLauncherBackgroundColor,
|
||||||
readAdaptiveIconBackgroundColor,
|
readAdaptiveIconBackgroundColor,
|
||||||
REQUIRED_LAUNCHER_ICON_FILES,
|
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';
|
} 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(corner, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||||
expect(colorDistance(center, WHITE)).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 { existsSync, readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import sharp from 'sharp';
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
@@ -9,13 +10,16 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ADAPTIVE_FOREGROUND_ICON_RATIO,
|
||||||
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||||
findMissingLauncherResources,
|
findMissingLauncherResources,
|
||||||
findStockCapacitorResources,
|
findStockCapacitorResources,
|
||||||
isBrandLauncherBackgroundColor,
|
isBrandLauncherBackgroundColor,
|
||||||
readAdaptiveIconBackgroundColor,
|
readAdaptiveIconBackgroundColor,
|
||||||
REQUIRED_LAUNCHER_ICON_FILES,
|
REQUIRED_LAUNCHER_ICON_FILES,
|
||||||
REQUIRED_SPLASH_FILES
|
REQUIRED_SPLASH_FILES,
|
||||||
|
resolveIconPixelSize,
|
||||||
|
SPLASH_ICON_RATIO
|
||||||
} from './mobile-android-launcher-icon.rules';
|
} from './mobile-android-launcher-icon.rules';
|
||||||
|
|
||||||
const RES_DIR = resolve(process.cwd(), 'android/app/src/main/res');
|
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 allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
||||||
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
|
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', () => {
|
it('ships a launcher icon and splash for every required density', () => {
|
||||||
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -49,4 +59,15 @@ describe('mobile-android-launcher-icon.rules', () => {
|
|||||||
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
|
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
|
||||||
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
|
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). */
|
/** Brand purple sampled from `images/icon-new-rounded.png` (the circle behind the cat). */
|
||||||
export const BRAND_LAUNCHER_BACKGROUND_COLOR = '#4A217A';
|
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. */
|
/** Density buckets Android resolves launcher icons from. */
|
||||||
export const ANDROID_ICON_DENSITIES = [
|
export const ANDROID_ICON_DENSITIES = [
|
||||||
'mdpi',
|
'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 = { r: 74, g: 33, b: 122 };
|
||||||
const BRAND_BACKGROUND_HEX = '#4A217A';
|
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. */
|
/** Legacy square/round launcher bitmap edge length per density. */
|
||||||
const LEGACY_ICON_PX = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 };
|
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();
|
.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() {
|
async function generateLauncherIcons() {
|
||||||
const written = [];
|
const written = [];
|
||||||
|
|
||||||
for (const [density, size] of Object.entries(LEGACY_ICON_PX)) {
|
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.png`));
|
||||||
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher_round.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)) {
|
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`));
|
written.push(await writePng(foreground, `mipmap-${density}/ic_launcher_foreground.png`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,14 +118,13 @@ async function generateSplashScreens() {
|
|||||||
const background = { ...BRAND_BACKGROUND, alpha: 1 };
|
const background = { ...BRAND_BACKGROUND, alpha: 1 };
|
||||||
|
|
||||||
for (const [density, [portW, portH]] of Object.entries(SPLASH_PORTRAIT)) {
|
for (const [density, [portW, portH]] of Object.entries(SPLASH_PORTRAIT)) {
|
||||||
const portrait = await centeredIconOnBackground(portW, portH, 0.4, background);
|
const portrait = await centeredIconOnBackground(portW, portH, SPLASH_ICON_RATIO, background);
|
||||||
const landscape = await centeredIconOnBackground(portH, portW, 0.4, 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(portrait, `drawable-port-${density}/splash.png`));
|
||||||
written.push(await writePng(landscape, `drawable-land-${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, SPLASH_ICON_RATIO, background);
|
||||||
const base = await centeredIconOnBackground(480, 320, 0.4, background);
|
|
||||||
written.push(await writePng(base, 'drawable/splash.png'));
|
written.push(await writePng(base, 'drawable/splash.png'));
|
||||||
|
|
||||||
return written;
|
return written;
|
||||||
|
|||||||