diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md
index 63c1fe3..033f359 100644
--- a/agents-docs/LESSONS.md
+++ b/agents-docs/LESSONS.md
@@ -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.
diff --git a/agents-docs/features/mobile-capacitor.md b/agents-docs/features/mobile-capacitor.md
index c95b247..d833891 100644
--- a/agents-docs/features/mobile-capacitor.md
+++ b/agents-docs/features/mobile-capacitor.md
@@ -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 |
diff --git a/e2e/tests/mobile/android-app-icon.spec.ts b/e2e/tests/mobile/android-app-icon.spec.ts
new file mode 100644
index 0000000..8069626
--- /dev/null
+++ b/e2e/tests/mobile/android-app-icon.spec.ts
@@ -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);
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 32f468d..591a78f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index df43d0b..ac2a396 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png
index e31573b..af67300 100644
Binary files a/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-land-hdpi/splash.png differ
diff --git a/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png
index f7a6492..1a74adf 100644
Binary files a/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-land-mdpi/splash.png differ
diff --git a/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png
index 8077255..cfe4e4f 100644
Binary files a/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-land-xhdpi/splash.png differ
diff --git a/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png
index 14c6c8f..d9103c5 100644
Binary files a/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ
diff --git a/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
index 244ca25..e8f13f8 100644
Binary files a/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ
diff --git a/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png
index 74faaa5..ef760b7 100644
Binary files a/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-port-hdpi/splash.png differ
diff --git a/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png
index e944f4a..7b972f0 100644
Binary files a/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-port-mdpi/splash.png differ
diff --git a/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png
index 564a82f..0b620fe 100644
Binary files a/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-port-xhdpi/splash.png differ
diff --git a/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png
index bfabe68..c66516e 100644
Binary files a/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ
diff --git a/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
index 6929071..989a7ed 100644
Binary files a/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png and b/toju-app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ
diff --git a/toju-app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/toju-app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
deleted file mode 100644
index c7bd21d..0000000
--- a/toju-app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/toju-app/android/app/src/main/res/drawable/splash.png b/toju-app/android/app/src/main/res/drawable/splash.png
index f7a6492..1a74adf 100644
Binary files a/toju-app/android/app/src/main/res/drawable/splash.png and b/toju-app/android/app/src/main/res/drawable/splash.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index c023e50..0a38f55 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
index 2127973..a2da718 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
index b441f37..0a38f55 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/toju-app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 72905b8..087fb6d 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
index 8ed0605..633f167 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
index 9502e47..087fb6d 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/toju-app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 4d1e077..357acc4 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
index df0f158..23d408a 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
index 853db04..357acc4 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/toju-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 6cdf97c..96603ef 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
index 2960cbb..6d3c95b 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
index 8e3093a..96603ef 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/toju-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 46de6e2..985a773 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
index d2ea9ab..75eabf3 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
index a40d73e..985a773 100644
Binary files a/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/toju-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/toju-app/android/app/src/main/res/values/ic_launcher_background.xml b/toju-app/android/app/src/main/res/values/ic_launcher_background.xml
index c5d5899..829e73b 100644
--- a/toju-app/android/app/src/main/res/values/ic_launcher_background.xml
+++ b/toju-app/android/app/src/main/res/values/ic_launcher_background.xml
@@ -1,4 +1,4 @@
- #FFFFFF
-
\ No newline at end of file
+ #4A217A
+
diff --git a/toju-app/src/app/infrastructure/mobile/README.md b/toju-app/src/app/infrastructure/mobile/README.md
index 529d2b1..079421d 100644
--- a/toju-app/src/app/infrastructure/mobile/README.md
+++ b/toju-app/src/app/infrastructure/mobile/README.md
@@ -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".
diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.spec.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.spec.ts
new file mode 100644
index 0000000..9a82952
--- /dev/null
+++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.spec.ts
@@ -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());
+ });
+});
diff --git a/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts
new file mode 100644
index 0000000..76e0c95
--- /dev/null
+++ b/toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts
@@ -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 = 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>,
+ defaultHashes: ReadonlySet = 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(/\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();
+}
diff --git a/tools/generate-android-app-icons.mjs b/tools/generate-android-app-icons.mjs
new file mode 100644
index 0000000..d3f8fb3
--- /dev/null
+++ b/tools/generate-android-app-icons.mjs
@@ -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 = `\n\n ${BRAND_BACKGROUND_HEX}\n\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;
+});