Files
Toju/e2e/tests/mobile/android-app-icon.spec.ts
Myx a01abbb1bf 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>
2026-06-11 21:20:01 +02:00

122 lines
5.1 KiB
TypeScript

import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
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,
SPLASH_ICON_RATIO
} from '../../../toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules';
/**
* Regression coverage for: "No android app icon" - the Capacitor shell shipped
* the stock Ionic placeholder launcher icon instead of the Toju brand mark.
*
* A native launcher icon cannot be asserted through a running browser, so this
* spec verifies the committed Android resources directly: every density is
* present, none still match a stock Capacitor placeholder, the adaptive-icon
* background is the brand purple, and the generated bitmaps actually contain the
* brand mark (white cat on a purple disc). This is deterministic - no emulator,
* no timing - so it stays reliable in CI.
*/
const REPO_ROOT = join(__dirname, '..', '..', '..');
const RES_DIR = join(REPO_ROOT, 'toju-app', 'android', 'app', 'src', 'main', 'res');
const BRAND_PURPLE = { r: 0x4a, g: 0x21, b: 0x7a };
const WHITE = { r: 255, g: 255, b: 255 };
const COLOR_TOLERANCE = 24;
function sha256(resRelativePath: string): string {
return createHash('sha256').update(readFileSync(join(RES_DIR, resRelativePath)))
.digest('hex');
}
function colorDistance(
left: { r: number; g: number; b: number },
right: { r: number; g: number; b: number }
): number {
return Math.max(Math.abs(left.r - right.r), Math.abs(left.g - right.g), Math.abs(left.b - right.b));
}
async function samplePixel(
resRelativePath: string,
xRatio: number,
yRatio: number
): Promise<{ r: number; g: number; b: number }> {
const { data, info } = await sharp(join(RES_DIR, resRelativePath)).raw()
.toBuffer({ resolveWithObject: true });
const x = Math.min(info.width - 1, Math.floor(info.width * xRatio));
const y = Math.min(info.height - 1, Math.floor(info.height * yRatio));
const offset = (y * info.width + x) * info.channels;
return { r: data[offset], g: data[offset + 1], b: data[offset + 2] };
}
test.describe('Android brand app icon', () => {
test('ships a launcher icon and splash for every required density', () => {
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
const present = allRequired.filter((file) => existsSync(join(RES_DIR, file)));
expect(findMissingLauncherResources(present)).toEqual([]);
});
test('replaces every stock Capacitor placeholder with the brand asset', () => {
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
const hashByFile = Object.fromEntries(
allRequired.filter((file) => existsSync(join(RES_DIR, file))).map((file) => [file, sha256(file)])
);
expect(findStockCapacitorResources(hashByFile)).toEqual([]);
});
test('uses the brand purple as the adaptive-icon background', () => {
const valuesXml = readFileSync(join(RES_DIR, 'values', 'ic_launcher_background.xml'), 'utf8');
const color = readAdaptiveIconBackgroundColor(valuesXml);
expect(color).not.toBe('#FFFFFF');
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
});
test('renders the brand mark (white cat on a purple disc) in the launcher bitmap', async () => {
const launcher = 'mipmap-xxxhdpi/ic_launcher.png';
const ringTop = await samplePixel(launcher, 0.5, 0.12);
const ringLeft = await samplePixel(launcher, 0.12, 0.5);
const faceCenter = await samplePixel(launcher, 0.5, 0.5);
expect(colorDistance(ringTop, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
expect(colorDistance(ringLeft, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
expect(colorDistance(faceCenter, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
});
test('renders the splash art as the brand mark centred on a purple field', async () => {
const splash = 'drawable-port-xhdpi/splash.png';
const corner = await samplePixel(splash, 0.04, 0.04);
const center = await samplePixel(splash, 0.5, 0.5);
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);
});
});