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

View File

@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
## Lessons
### Generate Android brand icons from the source mark; guard against stock Capacitor placeholders [mobile] [android] [assets]
- **Trigger:** the Android app shipped the default Ionic/Capacitor launcher icon (and a white adaptive background) because no brand icon was ever generated into `toju-app/android/app/src/main/res/`.
- **Rule:** regenerate launcher + splash from `images/icon-new-rounded.png` with `npm run cap:assets:android` (`tools/generate-android-app-icons.mjs`, uses `sharp`), set the adaptive background to brand purple `#4A217A` (never `#FFFFFF`), and have the adaptive icon reference `@mipmap/ic_launcher_foreground` PNGs (delete the stock `drawable-v24/ic_launcher_foreground.xml` vector). `cap:sync` is not needed — these live in the native project, not `webDir`.
- **Why:** a native launcher icon can't be asserted through a browser, so the regression proof is a hash guard: `mobile-android-launcher-icon.rules.ts` records the SHA-256 of every stock placeholder and the tests fail if any density still matches one. Pixel checks (purple ring + white-cat centre) confirm the brand mark actually rendered.
- **Example:** `findStockCapacitorResources(hashByFile)` must return `[]`; unit `mobile-android-launcher-icon.rules.spec.ts` + e2e `e2e/tests/mobile/android-app-icon.spec.ts` (deterministic fs/pixel checks, no emulator).
### Bind chat attachments to a pre-allocated message id, never by matching content [attachments] [chat] [mobile]
- **Trigger:** caption-less media (videos/images sent with no text) grouped onto the message bubble above and left an empty message below on Android — `ChatMessagesComponent` dispatched `sendMessage` without an id, then a `setTimeout` re-discovered the message by `entry.content === content` (always `''` for attachment-only sends) and called `publishAttachments` on it.

View File

@@ -58,6 +58,28 @@ Optional `google-services.json` is not injected in CI; push registration in arti
After dependency or plugin changes, run `npm run build:prod && npm run cap:sync` so native projects register `@capacitor/app`, `@capacitor-community/sqlite`, `@capawesome/capacitor-app-update`, push plugins, and `MetoyouMobile`.
## App icon & splash (Android brand assets)
The Capacitor shell must ship the Toju brand mark, not the stock Ionic/Capacitor placeholder. Brand resources are generated from `images/icon-new-rounded.png` (circular cat-on-purple disc) into `toju-app/android/app/src/main/res/`:
```bash
npm run cap:assets:android # → tools/generate-android-app-icons.mjs (uses sharp)
```
This produces, for every density (`mdpi … xxxhdpi`):
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc).
- `mipmap-*/ic_launcher_foreground.png` — full-bleed adaptive foreground; the adaptive layers in `mipmap-anydpi-v26/ic_launcher*.xml` reference `@mipmap/ic_launcher_foreground` (the stock `drawable-v24/ic_launcher_foreground.xml` vector is removed).
- `values/ic_launcher_background.xml` — adaptive background colour set to the **brand purple `#4A217A`**, not stock white.
- `drawable*/splash.png` (port + land per density, plus the base) — brand mark centred on a purple field for the launch splash.
Invariants are encoded in `toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts` (required file set, brand background colour, and the SHA-256 of every stock Capacitor placeholder that must never reappear). Coverage:
- Unit: `mobile-android-launcher-icon.rules.spec.ts` — asserts every density is present, no resource matches a stock placeholder hash, and the adaptive background is the brand purple.
- E2E: `e2e/tests/mobile/android-app-icon.spec.ts` — same contract plus pixel checks (launcher ring is purple, centre is the white cat; splash corner is purple, centre is the cat). Deterministic; no emulator.
Re-run `npm run cap:assets:android` whenever `images/icon-new-rounded.png` changes; `npm run cap:sync` is **not** needed (resources live in the native project, not `webDir`).
## Feature status
| Feature | Status | Notes |

View File

@@ -0,0 +1,107 @@
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 {
BRAND_LAUNCHER_BACKGROUND_COLOR,
findMissingLauncherResources,
findStockCapacitorResources,
isBrandLauncherBackgroundColor,
readAdaptiveIconBackgroundColor,
REQUIRED_LAUNCHER_ICON_FILES,
REQUIRED_SPLASH_FILES
} 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);
});
});

536
package-lock.json generated
View File

@@ -98,6 +98,7 @@
"pkg": "^5.8.1",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"sharp": "^0.34.4",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.2",
"typescript-eslint": "8.50.1",
@@ -5602,6 +5603,496 @@
"mlly": "^1.8.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@inquirer/ansi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
@@ -31838,6 +32329,51 @@
"node": ">=8"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -58,6 +58,7 @@
"test:e2e:report": "node e2e/run-playwright.mjs show-report ../test-results/html-report",
"perf:diag:view": "node tools/perf-diag-viewer.js",
"perf:diag:tail": "node tools/perf-diag-viewer.js --tail",
"cap:assets:android": "node tools/generate-android-app-icons.mjs",
"cap:sync": "cd toju-app && npx cap sync",
"cap:open:android": "node tools/cap-open-android.js",
"cap:open:ios": "cd toju-app && npx cap open ios",
@@ -158,6 +159,7 @@
"pkg": "^5.8.1",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"sharp": "^0.34.4",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.2",
"typescript-eslint": "8.50.1",

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

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env node
// Regenerates the Android launcher icons (adaptive + legacy) and splash screens
// for the Capacitor shell from the brand icon. Run with `npm run cap:assets:android`.
//
// Source of truth: images/icon-new-rounded.png (circular brand mark on a purple disc).
// Output: toju-app/android/app/src/main/res/{mipmap-*,drawable*,values}.
//
// The brand background colour and required output set are mirrored by
// toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts,
// which the Vitest suite asserts against.
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const SOURCE_ICON = resolve(REPO_ROOT, 'images/icon-new-rounded.png');
const RES_DIR = resolve(REPO_ROOT, 'toju-app/android/app/src/main/res');
/** Brand purple sampled from the brand icon disc. Keep in sync with the rules file. */
const BRAND_BACKGROUND = { r: 74, g: 33, b: 122 };
const BRAND_BACKGROUND_HEX = '#4A217A';
/** Legacy square/round launcher bitmap edge length per density. */
const LEGACY_ICON_PX = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 };
/** Adaptive foreground canvas edge length per density (108dp). */
const FOREGROUND_PX = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 };
/** Portrait splash dimensions per density; landscape swaps width/height. */
const SPLASH_PORTRAIT = {
mdpi: [320, 480],
hdpi: [480, 800],
xhdpi: [720, 1280],
xxhdpi: [960, 1600],
xxxhdpi: [1280, 1920]
};
async function ensureDir(filePath) {
await mkdir(dirname(filePath), { recursive: true });
}
async function writePng(buffer, resRelativePath) {
const outPath = resolve(RES_DIR, resRelativePath);
await ensureDir(outPath);
await writeFile(outPath, buffer);
return resRelativePath;
}
/** Resize the circular brand icon to a square of `size` px, preserving transparent corners. */
async function squareIcon(size) {
return sharp(SOURCE_ICON).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();
}
/** Compose the brand icon centred at `iconRatio` of the canvas over a solid background. */
async function centeredIconOnBackground(width, height, iconRatio, background) {
const iconSize = Math.round(Math.min(width, height) * iconRatio);
const icon = await squareIcon(iconSize);
return sharp({ create: { width, height, channels: 4, background } })
.composite([{ input: icon, gravity: 'center' }])
.png()
.toBuffer();
}
async function generateLauncherIcons() {
const written = [];
for (const [density, size] of Object.entries(LEGACY_ICON_PX)) {
const bitmap = await squareIcon(size);
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher.png`));
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher_round.png`));
}
// Adaptive foreground: full-bleed circle on transparent canvas. The adaptive
// background layer paints the brand purple, so the masked result matches the
// source disc on every launcher mask shape.
for (const [density, size] of Object.entries(FOREGROUND_PX)) {
const foreground = await squareIcon(size);
written.push(await writePng(foreground, `mipmap-${density}/ic_launcher_foreground.png`));
}
return written;
}
async function generateAdaptiveBackgroundColor() {
const xml = `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${BRAND_BACKGROUND_HEX}</color>\n</resources>\n`;
const outPath = resolve(RES_DIR, 'values/ic_launcher_background.xml');
await writeFile(outPath, xml);
return 'values/ic_launcher_background.xml';
}
async function generateSplashScreens() {
const written = [];
const background = { ...BRAND_BACKGROUND, alpha: 1 };
for (const [density, [portW, portH]] of Object.entries(SPLASH_PORTRAIT)) {
const portrait = await centeredIconOnBackground(portW, portH, 0.4, background);
const landscape = await centeredIconOnBackground(portH, portW, 0.4, background);
written.push(await writePng(portrait, `drawable-port-${density}/splash.png`));
written.push(await writePng(landscape, `drawable-land-${density}/splash.png`));
}
// Base fallback splash mirrors the landscape mdpi geometry (Capacitor default).
const base = await centeredIconOnBackground(480, 320, 0.4, background);
written.push(await writePng(base, 'drawable/splash.png'));
return written;
}
async function main() {
const written = [
...(await generateLauncherIcons()),
await generateAdaptiveBackgroundColor(),
...(await generateSplashScreens())
];
console.log(`Generated ${written.length} Android brand resources from ${SOURCE_ICON}:`);
for (const file of written) {
console.log(` res/${file}`);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});