import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import sharp from 'sharp'; import { describe, expect, it } from 'vitest'; import { ADAPTIVE_FOREGROUND_ICON_RATIO, BRAND_LAUNCHER_BACKGROUND_COLOR, findMissingLauncherResources, findStockCapacitorResources, isBrandLauncherBackgroundColor, NOTIFICATION_STATUS_ICON_NAME, readAdaptiveIconBackgroundColor, REQUIRED_LAUNCHER_ICON_FILES, REQUIRED_NOTIFICATION_ICON_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'); function sha256OfResource(resRelativePath: string): string { const bytes = readFileSync(resolve(RES_DIR, resRelativePath)); return createHash('sha256').update(bytes) .digest('hex'); } describe('mobile-android-launcher-icon.rules', () => { const allRequired = [ ...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES, ...REQUIRED_NOTIFICATION_ICON_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([]); }); it('ships a notification status-bar icon for every density', () => { expect(findMissingLauncherResources(presentFiles, REQUIRED_NOTIFICATION_ICON_FILES)).toEqual([]); }); it('references the notification status icon from the Capacitor config', () => { const capacitorConfig = readFileSync(resolve(process.cwd(), 'capacitor.config.ts'), 'utf8'); expect(capacitorConfig).toContain(`smallIcon: '${NOTIFICATION_STATUS_ICON_NAME}'`); }); it('renders the notification status icon as an alpha-only white glyph', async () => { const iconPath = resolve(RES_DIR, 'drawable-xxxhdpi/ic_stat_metoyou.png'); const { data, info } = await sharp(iconPath).ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); let opaquePixels = 0; let transparentPixels = 0; for (let offset = 0; offset < data.length; offset += info.channels) { const alpha = data[offset + 3]; if (alpha > 224) { opaquePixels += 1; expect(data[offset]).toBeGreaterThan(224); expect(data[offset + 1]).toBeGreaterThan(224); expect(data[offset + 2]).toBeGreaterThan(224); } else if (alpha < 32) { transparentPixels += 1; } } expect(opaquePixels).toBeGreaterThan(0); expect(transparentPixels).toBeGreaterThan(0); }); it('replaces every stock Capacitor placeholder with the brand asset', () => { const hashByFile = Object.fromEntries(presentFiles.map((file) => [file, sha256OfResource(file)])); expect(findStockCapacitorResources(hashByFile)).toEqual([]); }); it('uses the brand purple as the adaptive-icon background, not stock white', () => { const valuesXml = readFileSync(resolve(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()); }); 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); }); });