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