fix: Bug - No android app icon
This commit is contained in:
@@ -26,3 +26,5 @@ Loosely coupled Capacitor/native bridge for the Angular product client. Domains
|
||||
## Rules
|
||||
|
||||
Pure platform/call-notification rules live in `logic/*.rules.ts` and are Vitest-tested without Angular.
|
||||
|
||||
`logic/mobile-android-launcher-icon.rules.ts` encodes the Android brand launcher/splash contract (required densities, brand background colour `#4A217A`, and stock-Capacitor placeholder hashes to reject). Regenerate the resources from `images/icon-new-rounded.png` with `npm run cap:assets:android` (`tools/generate-android-app-icons.mjs`). See `agents-docs/features/mobile-capacitor.md` § "App icon & splash".
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||
findMissingLauncherResources,
|
||||
findStockCapacitorResources,
|
||||
isBrandLauncherBackgroundColor,
|
||||
readAdaptiveIconBackgroundColor,
|
||||
REQUIRED_LAUNCHER_ICON_FILES,
|
||||
REQUIRED_SPLASH_FILES
|
||||
} 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];
|
||||
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
|
||||
|
||||
it('ships a launcher icon and splash for every required density', () => {
|
||||
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
||||
});
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Rules describing the Android launcher icon and splash resources the MetoYou
|
||||
* Capacitor shell must ship. The brand icon (`images/icon-new-rounded.png`) must
|
||||
* replace the stock Capacitor/Ionic placeholder across every density, and the
|
||||
* adaptive-icon background must use the brand purple rather than white.
|
||||
*/
|
||||
|
||||
/** Brand purple sampled from `images/icon-new-rounded.png` (the circle behind the cat). */
|
||||
export const BRAND_LAUNCHER_BACKGROUND_COLOR = '#4A217A';
|
||||
|
||||
/** Density buckets Android resolves launcher icons from. */
|
||||
export const ANDROID_ICON_DENSITIES = [
|
||||
'mdpi',
|
||||
'hdpi',
|
||||
'xhdpi',
|
||||
'xxhdpi',
|
||||
'xxxhdpi'
|
||||
] as const;
|
||||
|
||||
/** Adaptive-icon layers plus the legacy square/round bitmaps each density must provide. */
|
||||
const LAUNCHER_ICON_BASENAMES = [
|
||||
'ic_launcher.png',
|
||||
'ic_launcher_round.png',
|
||||
'ic_launcher_foreground.png'
|
||||
] as const;
|
||||
|
||||
/** res-relative launcher icon files the brand build must contain (one per density × layer). */
|
||||
export const REQUIRED_LAUNCHER_ICON_FILES: readonly string[] = ANDROID_ICON_DENSITIES.flatMap((density) =>
|
||||
LAUNCHER_ICON_BASENAMES.map((basename) => `mipmap-${density}/${basename}`)
|
||||
);
|
||||
|
||||
/** res-relative splash files the brand build must contain (portrait + landscape per density, plus the base). */
|
||||
export const REQUIRED_SPLASH_FILES: readonly string[] = [
|
||||
'drawable/splash.png',
|
||||
...[
|
||||
'hdpi',
|
||||
'mdpi',
|
||||
'xhdpi',
|
||||
'xxhdpi',
|
||||
'xxxhdpi'
|
||||
].flatMap((density) => [`drawable-land-${density}/splash.png`, `drawable-port-${density}/splash.png`])
|
||||
];
|
||||
|
||||
/**
|
||||
* SHA-256 of every stock Capacitor placeholder resource that shipped before the
|
||||
* brand icon landed. A generated resource matching one of these means the brand
|
||||
* asset was never applied for that density, so it is treated as a regression.
|
||||
*/
|
||||
export const DEFAULT_CAPACITOR_RESOURCE_SHA256: ReadonlySet<string> = new Set([
|
||||
// Launcher icons (mipmap-*).
|
||||
'32baa10d2632a4417454a579f992bd640e0a3cec79321423559b2c9940de58a9',
|
||||
'72b71c3581ca3b5a23b1c168d69b9d855b3f184fa079902a01f088eb4f0607d5',
|
||||
'bfcc1b0fa931b14bb241372c76ab4f04374b67d02363c98d9cb12edfdacdf5f3',
|
||||
'58e78a618778926b1f6d9472a6468de878de8530970934e94aab5ba4ba08cc00',
|
||||
'27ed3603010ebc278f64f8645741ab132ff517abb5308eb9df6c8e42a48956b2',
|
||||
'0166fc333074c373fbd0ce6b5defd71552166165ac778121ca9c9dff6b83f0fc',
|
||||
'6f88083b8166cc559102f7044688de7525287632ebe09ac45d001ac8bf4b3eae',
|
||||
'd35dbfff175b83c13ef59cf924abfc810f7b6a158595d7417c5498ea8c7c7ed1',
|
||||
'40911a00922868686854a4804b93fd6e56b503664696de03f450bff690affb6d',
|
||||
'4a82bc1e9923576275869998925ce0ae021a79aa18b24a0dd87ad6b61ca85053',
|
||||
'ed346eb1e3f0280f15709393705899b3ff55c20b88f4e0308006b3c33cf5fe14',
|
||||
'1ee4cd9ff371dcb2e3938097e434f6fb8731688ed7165e61fc63693ad5b2f455',
|
||||
'bd24fd383253bf8d43f0a81f11c071d76d1d555114376dd647cd9fb38fa0a9da',
|
||||
'87cb2f2ffe992652bb4fa768c73719a37b5852ab17fbf8e170e888f7a42b0761',
|
||||
'ab93096331e7cd8ec379f73f1e9adcaaa9ee1115c9f4ff10411a811fb9700174',
|
||||
// Splash screens (drawable-*).
|
||||
'08cc34ad7713fe7ed58bceaa37b2387b670c53cd60264b4bd6442db3098e75dc',
|
||||
'5cf98b4451bd99b20df26f9e608a46946118be6b0ae90762f9ca1786a30c76ff',
|
||||
'22f87e1e3bc89aa01a7dbc39c9a4db058cd0bf4ad3fe9f55712bf69eb997f4bf',
|
||||
'42aa26392546fcdee1b8d3ac6d4b41bfcceb41dc6a4f3a3c30c24a8a8f4db862',
|
||||
'60393ce8636fd263e4e1fea3fd4ab2de948c6295e898fda9b50ac4e5283be809',
|
||||
'c5015f4ba3628392b538386c5e210f0b94f352a3160adab934fd0311972137ca',
|
||||
'07fa579e1c83e04ba7f9cbcbfcf41b68e15fe3638f2c44a04e58b809103e6b69',
|
||||
'b73049cb37fe76d6c11b87a796766bf6af0c85483b31eb6a921657b0d764a4b9',
|
||||
'0c7f1212f25b7b90e9a6e1d320013e4ff3d3e03e634cbb07b7b7981cac51627f',
|
||||
'3db071a03b2f8ffe0dfd4170fc59842d53cd15bba5e88af59401d58efabf7827'
|
||||
]);
|
||||
|
||||
/** Return required resources missing from the set of files actually present on disk. */
|
||||
export function findMissingLauncherResources(
|
||||
existingResFiles: readonly string[],
|
||||
requiredFiles: readonly string[] = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES]
|
||||
): string[] {
|
||||
const present = new Set(existingResFiles);
|
||||
|
||||
return requiredFiles.filter((file) => !present.has(file));
|
||||
}
|
||||
|
||||
/** Return resource files whose content still matches a stock Capacitor placeholder hash. */
|
||||
export function findStockCapacitorResources(
|
||||
resourceSha256ByFile: Readonly<Record<string, string>>,
|
||||
defaultHashes: ReadonlySet<string> = DEFAULT_CAPACITOR_RESOURCE_SHA256
|
||||
): string[] {
|
||||
return Object.entries(resourceSha256ByFile)
|
||||
.filter(([, hash]) => defaultHashes.has(hash.toLowerCase()))
|
||||
.map(([file]) => file)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Extract the `ic_launcher_background` colour from a `values/ic_launcher_background.xml` source. */
|
||||
export function readAdaptiveIconBackgroundColor(valuesXml: string): string | null {
|
||||
const match = valuesXml.match(/<color\s+name="ic_launcher_background"\s*>\s*(#[0-9a-fA-F]{3,8})\s*<\/color>/);
|
||||
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** True when the adaptive-icon background is the brand purple (case-insensitive), not the stock white. */
|
||||
export function isBrandLauncherBackgroundColor(
|
||||
color: string | null,
|
||||
brandColor: string = BRAND_LAUNCHER_BACKGROUND_COLOR
|
||||
): boolean {
|
||||
return color !== null && color.toLowerCase() === brandColor.toLowerCase();
|
||||
}
|
||||
Reference in New Issue
Block a user