fix: Bug - No android app icon
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 233 KiB |
@@ -1,34 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 26 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
<color name="ic_launcher_background">#4A217A</color>
|
||||
</resources>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||