fix: Bug - No android app icon

This commit is contained in:
2026-06-11 03:03:10 +02:00
parent d72a027c9a
commit 49b602dbda
37 changed files with 972 additions and 36 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 233 KiB

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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>

View File

@@ -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".

View File

@@ -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());
});
});

View File

@@ -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();
}