fix: Bug - No android app icon
@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## 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]
|
### 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.
|
- **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.
|
||||||
|
|||||||
@@ -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`.
|
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
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
| Feature | Status | Notes |
|
||||||
|
|||||||
107
e2e/tests/mobile/android-app-icon.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
import { test, expect } from '../../fixtures/base';
|
||||||
|
import {
|
||||||
|
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||||
|
findMissingLauncherResources,
|
||||||
|
findStockCapacitorResources,
|
||||||
|
isBrandLauncherBackgroundColor,
|
||||||
|
readAdaptiveIconBackgroundColor,
|
||||||
|
REQUIRED_LAUNCHER_ICON_FILES,
|
||||||
|
REQUIRED_SPLASH_FILES
|
||||||
|
} from '../../../toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for: "No android app icon" - the Capacitor shell shipped
|
||||||
|
* the stock Ionic placeholder launcher icon instead of the Toju brand mark.
|
||||||
|
*
|
||||||
|
* A native launcher icon cannot be asserted through a running browser, so this
|
||||||
|
* spec verifies the committed Android resources directly: every density is
|
||||||
|
* present, none still match a stock Capacitor placeholder, the adaptive-icon
|
||||||
|
* background is the brand purple, and the generated bitmaps actually contain the
|
||||||
|
* brand mark (white cat on a purple disc). This is deterministic - no emulator,
|
||||||
|
* no timing - so it stays reliable in CI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REPO_ROOT = join(__dirname, '..', '..', '..');
|
||||||
|
const RES_DIR = join(REPO_ROOT, 'toju-app', 'android', 'app', 'src', 'main', 'res');
|
||||||
|
const BRAND_PURPLE = { r: 0x4a, g: 0x21, b: 0x7a };
|
||||||
|
const WHITE = { r: 255, g: 255, b: 255 };
|
||||||
|
const COLOR_TOLERANCE = 24;
|
||||||
|
|
||||||
|
function sha256(resRelativePath: string): string {
|
||||||
|
return createHash('sha256').update(readFileSync(join(RES_DIR, resRelativePath)))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorDistance(
|
||||||
|
left: { r: number; g: number; b: number },
|
||||||
|
right: { r: number; g: number; b: number }
|
||||||
|
): number {
|
||||||
|
return Math.max(Math.abs(left.r - right.r), Math.abs(left.g - right.g), Math.abs(left.b - right.b));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function samplePixel(
|
||||||
|
resRelativePath: string,
|
||||||
|
xRatio: number,
|
||||||
|
yRatio: number
|
||||||
|
): Promise<{ r: number; g: number; b: number }> {
|
||||||
|
const { data, info } = await sharp(join(RES_DIR, resRelativePath)).raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
const x = Math.min(info.width - 1, Math.floor(info.width * xRatio));
|
||||||
|
const y = Math.min(info.height - 1, Math.floor(info.height * yRatio));
|
||||||
|
const offset = (y * info.width + x) * info.channels;
|
||||||
|
|
||||||
|
return { r: data[offset], g: data[offset + 1], b: data[offset + 2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Android brand app icon', () => {
|
||||||
|
test('ships a launcher icon and splash for every required density', () => {
|
||||||
|
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
||||||
|
const present = allRequired.filter((file) => existsSync(join(RES_DIR, file)));
|
||||||
|
|
||||||
|
expect(findMissingLauncherResources(present)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces every stock Capacitor placeholder with the brand asset', () => {
|
||||||
|
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
||||||
|
const hashByFile = Object.fromEntries(
|
||||||
|
allRequired.filter((file) => existsSync(join(RES_DIR, file))).map((file) => [file, sha256(file)])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findStockCapacitorResources(hashByFile)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the brand purple as the adaptive-icon background', () => {
|
||||||
|
const valuesXml = readFileSync(join(RES_DIR, 'values', 'ic_launcher_background.xml'), 'utf8');
|
||||||
|
const color = readAdaptiveIconBackgroundColor(valuesXml);
|
||||||
|
|
||||||
|
expect(color).not.toBe('#FFFFFF');
|
||||||
|
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
|
||||||
|
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the brand mark (white cat on a purple disc) in the launcher bitmap', async () => {
|
||||||
|
const launcher = 'mipmap-xxxhdpi/ic_launcher.png';
|
||||||
|
const ringTop = await samplePixel(launcher, 0.5, 0.12);
|
||||||
|
const ringLeft = await samplePixel(launcher, 0.12, 0.5);
|
||||||
|
const faceCenter = await samplePixel(launcher, 0.5, 0.5);
|
||||||
|
|
||||||
|
expect(colorDistance(ringTop, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||||
|
expect(colorDistance(ringLeft, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||||
|
expect(colorDistance(faceCenter, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the splash art as the brand mark centred on a purple field', async () => {
|
||||||
|
const splash = 'drawable-port-xhdpi/splash.png';
|
||||||
|
const corner = await samplePixel(splash, 0.04, 0.04);
|
||||||
|
const center = await samplePixel(splash, 0.5, 0.5);
|
||||||
|
|
||||||
|
expect(colorDistance(corner, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||||
|
expect(colorDistance(center, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||||
|
});
|
||||||
|
});
|
||||||
536
package-lock.json
generated
@@ -98,6 +98,7 @@
|
|||||||
"pkg": "^5.8.1",
|
"pkg": "^5.8.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.50.1",
|
"typescript-eslint": "8.50.1",
|
||||||
@@ -5602,6 +5603,496 @@
|
|||||||
"mlly": "^1.8.0"
|
"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": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
||||||
@@ -31838,6 +32329,51 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"test:e2e:report": "node e2e/run-playwright.mjs show-report ../test-results/html-report",
|
"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:view": "node tools/perf-diag-viewer.js",
|
||||||
"perf:diag:tail": "node tools/perf-diag-viewer.js --tail",
|
"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:sync": "cd toju-app && npx cap sync",
|
||||||
"cap:open:android": "node tools/cap-open-android.js",
|
"cap:open:android": "node tools/cap-open-android.js",
|
||||||
"cap:open:ios": "cd toju-app && npx cap open ios",
|
"cap:open:ios": "cd toju-app && npx cap open ios",
|
||||||
@@ -158,6 +159,7 @@
|
|||||||
"pkg": "^5.8.1",
|
"pkg": "^5.8.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"typescript-eslint": "8.50.1",
|
"typescript-eslint": "8.50.1",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 233 KiB |
@@ -1,34 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportHeight="108"
|
|
||||||
android:viewportWidth="108">
|
|
||||||
<path
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="78.5885"
|
|
||||||
android:endY="90.9159"
|
|
||||||
android:startX="48.7653"
|
|
||||||
android:startY="61.0927"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1" />
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 26 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#FFFFFF</color>
|
<color name="ic_launcher_background">#4A217A</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -26,3 +26,5 @@ Loosely coupled Capacitor/native bridge for the Angular product client. Domains
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
Pure platform/call-notification rules live in `logic/*.rules.ts` and are Vitest-tested without Angular.
|
Pure platform/call-notification rules live in `logic/*.rules.ts` and are Vitest-tested without Angular.
|
||||||
|
|
||||||
|
`logic/mobile-android-launcher-icon.rules.ts` encodes the Android brand launcher/splash contract (required densities, brand background colour `#4A217A`, and stock-Capacitor placeholder hashes to reject). Regenerate the resources from `images/icon-new-rounded.png` with `npm run cap:assets:android` (`tools/generate-android-app-icons.mjs`). See `agents-docs/features/mobile-capacitor.md` § "App icon & splash".
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||||
|
findMissingLauncherResources,
|
||||||
|
findStockCapacitorResources,
|
||||||
|
isBrandLauncherBackgroundColor,
|
||||||
|
readAdaptiveIconBackgroundColor,
|
||||||
|
REQUIRED_LAUNCHER_ICON_FILES,
|
||||||
|
REQUIRED_SPLASH_FILES
|
||||||
|
} from './mobile-android-launcher-icon.rules';
|
||||||
|
|
||||||
|
const RES_DIR = resolve(process.cwd(), 'android/app/src/main/res');
|
||||||
|
|
||||||
|
function sha256OfResource(resRelativePath: string): string {
|
||||||
|
const bytes = readFileSync(resolve(RES_DIR, resRelativePath));
|
||||||
|
|
||||||
|
return createHash('sha256').update(bytes)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mobile-android-launcher-icon.rules', () => {
|
||||||
|
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
||||||
|
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
|
||||||
|
|
||||||
|
it('ships a launcher icon and splash for every required density', () => {
|
||||||
|
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces every stock Capacitor placeholder with the brand asset', () => {
|
||||||
|
const hashByFile = Object.fromEntries(presentFiles.map((file) => [file, sha256OfResource(file)]));
|
||||||
|
|
||||||
|
expect(findStockCapacitorResources(hashByFile)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the brand purple as the adaptive-icon background, not stock white', () => {
|
||||||
|
const valuesXml = readFileSync(resolve(RES_DIR, 'values/ic_launcher_background.xml'), 'utf8');
|
||||||
|
const color = readAdaptiveIconBackgroundColor(valuesXml);
|
||||||
|
|
||||||
|
expect(color).not.toBe('#FFFFFF');
|
||||||
|
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
|
||||||
|
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Rules describing the Android launcher icon and splash resources the MetoYou
|
||||||
|
* Capacitor shell must ship. The brand icon (`images/icon-new-rounded.png`) must
|
||||||
|
* replace the stock Capacitor/Ionic placeholder across every density, and the
|
||||||
|
* adaptive-icon background must use the brand purple rather than white.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Brand purple sampled from `images/icon-new-rounded.png` (the circle behind the cat). */
|
||||||
|
export const BRAND_LAUNCHER_BACKGROUND_COLOR = '#4A217A';
|
||||||
|
|
||||||
|
/** Density buckets Android resolves launcher icons from. */
|
||||||
|
export const ANDROID_ICON_DENSITIES = [
|
||||||
|
'mdpi',
|
||||||
|
'hdpi',
|
||||||
|
'xhdpi',
|
||||||
|
'xxhdpi',
|
||||||
|
'xxxhdpi'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Adaptive-icon layers plus the legacy square/round bitmaps each density must provide. */
|
||||||
|
const LAUNCHER_ICON_BASENAMES = [
|
||||||
|
'ic_launcher.png',
|
||||||
|
'ic_launcher_round.png',
|
||||||
|
'ic_launcher_foreground.png'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** res-relative launcher icon files the brand build must contain (one per density × layer). */
|
||||||
|
export const REQUIRED_LAUNCHER_ICON_FILES: readonly string[] = ANDROID_ICON_DENSITIES.flatMap((density) =>
|
||||||
|
LAUNCHER_ICON_BASENAMES.map((basename) => `mipmap-${density}/${basename}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** res-relative splash files the brand build must contain (portrait + landscape per density, plus the base). */
|
||||||
|
export const REQUIRED_SPLASH_FILES: readonly string[] = [
|
||||||
|
'drawable/splash.png',
|
||||||
|
...[
|
||||||
|
'hdpi',
|
||||||
|
'mdpi',
|
||||||
|
'xhdpi',
|
||||||
|
'xxhdpi',
|
||||||
|
'xxxhdpi'
|
||||||
|
].flatMap((density) => [`drawable-land-${density}/splash.png`, `drawable-port-${density}/splash.png`])
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 of every stock Capacitor placeholder resource that shipped before the
|
||||||
|
* brand icon landed. A generated resource matching one of these means the brand
|
||||||
|
* asset was never applied for that density, so it is treated as a regression.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CAPACITOR_RESOURCE_SHA256: ReadonlySet<string> = new Set([
|
||||||
|
// Launcher icons (mipmap-*).
|
||||||
|
'32baa10d2632a4417454a579f992bd640e0a3cec79321423559b2c9940de58a9',
|
||||||
|
'72b71c3581ca3b5a23b1c168d69b9d855b3f184fa079902a01f088eb4f0607d5',
|
||||||
|
'bfcc1b0fa931b14bb241372c76ab4f04374b67d02363c98d9cb12edfdacdf5f3',
|
||||||
|
'58e78a618778926b1f6d9472a6468de878de8530970934e94aab5ba4ba08cc00',
|
||||||
|
'27ed3603010ebc278f64f8645741ab132ff517abb5308eb9df6c8e42a48956b2',
|
||||||
|
'0166fc333074c373fbd0ce6b5defd71552166165ac778121ca9c9dff6b83f0fc',
|
||||||
|
'6f88083b8166cc559102f7044688de7525287632ebe09ac45d001ac8bf4b3eae',
|
||||||
|
'd35dbfff175b83c13ef59cf924abfc810f7b6a158595d7417c5498ea8c7c7ed1',
|
||||||
|
'40911a00922868686854a4804b93fd6e56b503664696de03f450bff690affb6d',
|
||||||
|
'4a82bc1e9923576275869998925ce0ae021a79aa18b24a0dd87ad6b61ca85053',
|
||||||
|
'ed346eb1e3f0280f15709393705899b3ff55c20b88f4e0308006b3c33cf5fe14',
|
||||||
|
'1ee4cd9ff371dcb2e3938097e434f6fb8731688ed7165e61fc63693ad5b2f455',
|
||||||
|
'bd24fd383253bf8d43f0a81f11c071d76d1d555114376dd647cd9fb38fa0a9da',
|
||||||
|
'87cb2f2ffe992652bb4fa768c73719a37b5852ab17fbf8e170e888f7a42b0761',
|
||||||
|
'ab93096331e7cd8ec379f73f1e9adcaaa9ee1115c9f4ff10411a811fb9700174',
|
||||||
|
// Splash screens (drawable-*).
|
||||||
|
'08cc34ad7713fe7ed58bceaa37b2387b670c53cd60264b4bd6442db3098e75dc',
|
||||||
|
'5cf98b4451bd99b20df26f9e608a46946118be6b0ae90762f9ca1786a30c76ff',
|
||||||
|
'22f87e1e3bc89aa01a7dbc39c9a4db058cd0bf4ad3fe9f55712bf69eb997f4bf',
|
||||||
|
'42aa26392546fcdee1b8d3ac6d4b41bfcceb41dc6a4f3a3c30c24a8a8f4db862',
|
||||||
|
'60393ce8636fd263e4e1fea3fd4ab2de948c6295e898fda9b50ac4e5283be809',
|
||||||
|
'c5015f4ba3628392b538386c5e210f0b94f352a3160adab934fd0311972137ca',
|
||||||
|
'07fa579e1c83e04ba7f9cbcbfcf41b68e15fe3638f2c44a04e58b809103e6b69',
|
||||||
|
'b73049cb37fe76d6c11b87a796766bf6af0c85483b31eb6a921657b0d764a4b9',
|
||||||
|
'0c7f1212f25b7b90e9a6e1d320013e4ff3d3e03e634cbb07b7b7981cac51627f',
|
||||||
|
'3db071a03b2f8ffe0dfd4170fc59842d53cd15bba5e88af59401d58efabf7827'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Return required resources missing from the set of files actually present on disk. */
|
||||||
|
export function findMissingLauncherResources(
|
||||||
|
existingResFiles: readonly string[],
|
||||||
|
requiredFiles: readonly string[] = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES]
|
||||||
|
): string[] {
|
||||||
|
const present = new Set(existingResFiles);
|
||||||
|
|
||||||
|
return requiredFiles.filter((file) => !present.has(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return resource files whose content still matches a stock Capacitor placeholder hash. */
|
||||||
|
export function findStockCapacitorResources(
|
||||||
|
resourceSha256ByFile: Readonly<Record<string, string>>,
|
||||||
|
defaultHashes: ReadonlySet<string> = DEFAULT_CAPACITOR_RESOURCE_SHA256
|
||||||
|
): string[] {
|
||||||
|
return Object.entries(resourceSha256ByFile)
|
||||||
|
.filter(([, hash]) => defaultHashes.has(hash.toLowerCase()))
|
||||||
|
.map(([file]) => file)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the `ic_launcher_background` colour from a `values/ic_launcher_background.xml` source. */
|
||||||
|
export function readAdaptiveIconBackgroundColor(valuesXml: string): string | null {
|
||||||
|
const match = valuesXml.match(/<color\s+name="ic_launcher_background"\s*>\s*(#[0-9a-fA-F]{3,8})\s*<\/color>/);
|
||||||
|
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the adaptive-icon background is the brand purple (case-insensitive), not the stock white. */
|
||||||
|
export function isBrandLauncherBackgroundColor(
|
||||||
|
color: string | null,
|
||||||
|
brandColor: string = BRAND_LAUNCHER_BACKGROUND_COLOR
|
||||||
|
): boolean {
|
||||||
|
return color !== null && color.toLowerCase() === brandColor.toLowerCase();
|
||||||
|
}
|
||||||
129
tools/generate-android-app-icons.mjs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Regenerates the Android launcher icons (adaptive + legacy) and splash screens
|
||||||
|
// for the Capacitor shell from the brand icon. Run with `npm run cap:assets:android`.
|
||||||
|
//
|
||||||
|
// Source of truth: images/icon-new-rounded.png (circular brand mark on a purple disc).
|
||||||
|
// Output: toju-app/android/app/src/main/res/{mipmap-*,drawable*,values}.
|
||||||
|
//
|
||||||
|
// The brand background colour and required output set are mirrored by
|
||||||
|
// toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts,
|
||||||
|
// which the Vitest suite asserts against.
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const SOURCE_ICON = resolve(REPO_ROOT, 'images/icon-new-rounded.png');
|
||||||
|
const RES_DIR = resolve(REPO_ROOT, 'toju-app/android/app/src/main/res');
|
||||||
|
|
||||||
|
/** Brand purple sampled from the brand icon disc. Keep in sync with the rules file. */
|
||||||
|
const BRAND_BACKGROUND = { r: 74, g: 33, b: 122 };
|
||||||
|
const BRAND_BACKGROUND_HEX = '#4A217A';
|
||||||
|
|
||||||
|
/** Legacy square/round launcher bitmap edge length per density. */
|
||||||
|
const LEGACY_ICON_PX = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 };
|
||||||
|
|
||||||
|
/** Adaptive foreground canvas edge length per density (108dp). */
|
||||||
|
const FOREGROUND_PX = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 };
|
||||||
|
|
||||||
|
/** Portrait splash dimensions per density; landscape swaps width/height. */
|
||||||
|
const SPLASH_PORTRAIT = {
|
||||||
|
mdpi: [320, 480],
|
||||||
|
hdpi: [480, 800],
|
||||||
|
xhdpi: [720, 1280],
|
||||||
|
xxhdpi: [960, 1600],
|
||||||
|
xxxhdpi: [1280, 1920]
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ensureDir(filePath) {
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePng(buffer, resRelativePath) {
|
||||||
|
const outPath = resolve(RES_DIR, resRelativePath);
|
||||||
|
await ensureDir(outPath);
|
||||||
|
await writeFile(outPath, buffer);
|
||||||
|
return resRelativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resize the circular brand icon to a square of `size` px, preserving transparent corners. */
|
||||||
|
async function squareIcon(size) {
|
||||||
|
return sharp(SOURCE_ICON).resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compose the brand icon centred at `iconRatio` of the canvas over a solid background. */
|
||||||
|
async function centeredIconOnBackground(width, height, iconRatio, background) {
|
||||||
|
const iconSize = Math.round(Math.min(width, height) * iconRatio);
|
||||||
|
const icon = await squareIcon(iconSize);
|
||||||
|
|
||||||
|
return sharp({ create: { width, height, channels: 4, background } })
|
||||||
|
.composite([{ input: icon, gravity: 'center' }])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateLauncherIcons() {
|
||||||
|
const written = [];
|
||||||
|
|
||||||
|
for (const [density, size] of Object.entries(LEGACY_ICON_PX)) {
|
||||||
|
const bitmap = await squareIcon(size);
|
||||||
|
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher.png`));
|
||||||
|
written.push(await writePng(bitmap, `mipmap-${density}/ic_launcher_round.png`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptive foreground: full-bleed circle on transparent canvas. The adaptive
|
||||||
|
// background layer paints the brand purple, so the masked result matches the
|
||||||
|
// source disc on every launcher mask shape.
|
||||||
|
for (const [density, size] of Object.entries(FOREGROUND_PX)) {
|
||||||
|
const foreground = await squareIcon(size);
|
||||||
|
written.push(await writePng(foreground, `mipmap-${density}/ic_launcher_foreground.png`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateAdaptiveBackgroundColor() {
|
||||||
|
const xml = `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${BRAND_BACKGROUND_HEX}</color>\n</resources>\n`;
|
||||||
|
const outPath = resolve(RES_DIR, 'values/ic_launcher_background.xml');
|
||||||
|
await writeFile(outPath, xml);
|
||||||
|
return 'values/ic_launcher_background.xml';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSplashScreens() {
|
||||||
|
const written = [];
|
||||||
|
const background = { ...BRAND_BACKGROUND, alpha: 1 };
|
||||||
|
|
||||||
|
for (const [density, [portW, portH]] of Object.entries(SPLASH_PORTRAIT)) {
|
||||||
|
const portrait = await centeredIconOnBackground(portW, portH, 0.4, background);
|
||||||
|
const landscape = await centeredIconOnBackground(portH, portW, 0.4, background);
|
||||||
|
written.push(await writePng(portrait, `drawable-port-${density}/splash.png`));
|
||||||
|
written.push(await writePng(landscape, `drawable-land-${density}/splash.png`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base fallback splash mirrors the landscape mdpi geometry (Capacitor default).
|
||||||
|
const base = await centeredIconOnBackground(480, 320, 0.4, background);
|
||||||
|
written.push(await writePng(base, 'drawable/splash.png'));
|
||||||
|
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const written = [
|
||||||
|
...(await generateLauncherIcons()),
|
||||||
|
await generateAdaptiveBackgroundColor(),
|
||||||
|
...(await generateSplashScreens())
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(`Generated ${written.length} Android brand resources from ${SOURCE_ICON}:`);
|
||||||
|
for (const file of written) {
|
||||||
|
console.log(` res/${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||