diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 8e65be7..3500ccf 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -20,11 +20,11 @@ android {
targetSdk = 34
// Production release (mission 2026-10-04 §14): production label/icons,
// release build in CI. Debug APK stays available for engineering.
- versionCode = 18
+ versionCode = 19
// maintenance: recovered-transfer result-average scope fix (display
// telemetry only — wire protocol, durability, resume, SHA-256 and
// performance byte-identical to 1.4.2)
- versionName = "1.4.5"
+ versionName = "1.4.6"
// v1.5 Phase C: instrumented preview tests (PreviewInstrumentedTest)
// run on the CI emulator via :app:connectedDebugAndroidTest.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
index fe00a61..8d221cc 100644
Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
index 2b4b472..53dbf12 100644
Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
index 1bd4570..4331def 100644
Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
index ec65a6e..d6ae95d 100644
Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
index 735fd2e..9e998e6 100644
Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
index b389ffb..f219885 100644
Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
index a3851e3..95abb92 100644
Binary files a/public/icons/icon-192.png and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
index 818ac98..ec0ba17 100644
Binary files a/public/icons/icon-512.png and b/public/icons/icon-512.png differ
diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png
index db5bebc..0877c25 100644
Binary files a/public/icons/icon-maskable-512.png and b/public/icons/icon-maskable-512.png differ
diff --git a/public/icons/icon.svg b/public/icons/icon.svg
index 6842f5f..c924f19 100644
--- a/public/icons/icon.svg
+++ b/public/icons/icon.svg
@@ -1,6 +1 @@
-
+
diff --git a/tests/branding.test.ts b/tests/branding.test.ts
index 64d5fd4..b973a0e 100644
--- a/tests/branding.test.ts
+++ b/tests/branding.test.ts
@@ -1,5 +1,5 @@
/**
- * OFFICIAL NEXDROP BRANDING REGRESSION SUITE (Phase 2, 2026-10-15)
+ * OFFICIAL NEXDROP BRANDING REGRESSION SUITE (Phase 2.1, 2026-10-16)
* -------------------------------------------------------------------------
* Purpose: the launcher icon and branding assets are BAKED into the APK at
* build time. A web-asset swap can never fix a released binary, and a silent
@@ -8,29 +8,31 @@
* - any Android launcher foreground is missing, has the wrong canvas size,
* or no longer matches the pinned official-artwork fingerprint
* (old-artwork detection);
+ * - the launcher foreground no longer carries the COMPLETE official
+ * artwork — the content aspect ratio must match the full master's
+ * symbol + wordmark + tagline composition (963 x 847, ratio ~0.88).
+ * A symbol-only foreground (the rejected Phase 2 N-mark, ratio ~0.63)
+ * cannot pass this check (Phase 2.1 acceptance criterion);
+ * - the content bounding box leaves the 66/108 adaptive safe circle,
+ * or drifts off-center (mask clipping / cropping detection);
* - the adaptive-icon background color resource is no longer the official
* artwork's white background;
* - the adaptive-icon XML / manifest icon references drift;
* - the in-app branding drawables (logo_mark, welcome_hero) change from
* the official artwork derivatives;
- * - the PWA icon set (192/512/maskable-512/apple-touch/svg favicon) drifts;
+ * - the PWA icon set (192/512/maskable-512/apple-touch/svg favicon) drifts
+ * from the complete official artwork;
* - the master artwork (public/nexdrop-logo-official.png) is not the
* supplied single source of truth.
*
- * Geometry guarantee (validated when these files were generated, and pinned
- * here by hash): the launcher foreground content was scaled so its bounding
- * box DIAGONAL fits inside the 66/108 adaptive safe circle — no launcher
- * mask (circle, squircle, rounded square) can clip the mark.
- *
- * The launcher foreground uses the distinctive N-mark extracted from the
- * official artwork (approved launcher-specific simplification; the full
- * artwork with wordmark + PRIVATE. DIRECT. FAST. tagline ships on the web
- * and larger surfaces). Regenerate the pinned hashes ONLY after an
+ * Geometry is verified against DECODED PIXELS (dependency-free PNG reader),
+ * not just pinned hashes. Regenerate the pinned hashes ONLY after an
* intentional, approved rebrand — never to silence a failure.
*/
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
+import { inflateSync } from 'node:zlib';
const sha256 = (p: string): string =>
createHash('sha256').update(readFileSync(p)).digest('hex');
@@ -43,95 +45,213 @@ const pngSize = (p: string): { width: number; height: number } => {
return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
};
+/**
+ * Minimal dependency-free PNG decoder for 8-bit RGB (color type 2) or RGBA
+ * (color type 6), non-interlaced. Returns rows of [R,G,B,A] samples so the
+ * suite can measure the real content bounding box of a committed resource.
+ */
+const decodePng = (p: string): { width: number; height: number; rgba: Buffer } => {
+ const b = readFileSync(p);
+ assert.equal(b.readUInt32BE(12), 0x49484452, `${p}: missing IHDR`);
+ const width = b.readUInt32BE(16);
+ const height = b.readUInt32BE(20);
+ const depth = b[24];
+ const colorType = b[25];
+ assert.equal(depth, 8, `${p}: expected 8-bit PNG`);
+ assert.ok(colorType === 2 || colorType === 6, `${p}: expected RGB(A) PNG`);
+ const bpp = colorType === 6 ? 4 : 3;
+ // concatenate IDAT chunks
+ const chunks: Buffer[] = [];
+ let off = 8;
+ while (off < b.length) {
+ const len = b.readUInt32BE(off);
+ const type = b.toString('ascii', off + 4, off + 8);
+ if (type === 'IDAT') chunks.push(b.subarray(off + 8, off + 8 + len));
+ if (type === 'IEND') break;
+ off += 12 + len;
+ }
+ const raw = inflateSync(Buffer.concat(chunks));
+ const stride = width * bpp;
+ const out = Buffer.alloc(height * stride);
+ const prev = Buffer.alloc(stride);
+ let rp = 0;
+ for (let y = 0; y < height; y++) {
+ const filter = raw[rp++];
+ const row = raw.subarray(rp, rp + stride);
+ rp += stride;
+ const dst = out.subarray(y * stride, (y + 1) * stride);
+ for (let x = 0; x < stride; x++) {
+ const a = x >= bpp ? dst[x - bpp] : 0;
+ const bb = y > 0 ? prev[x] : 0;
+ const c = x >= bpp && y > 0 ? prev[x - bpp] : 0;
+ let v = row[x];
+ if (filter === 1) v = (v + a) & 0xff;
+ else if (filter === 2) v = (v + bb) & 0xff;
+ else if (filter === 3) v = (v + ((a + bb) >> 1)) & 0xff;
+ else if (filter === 4) {
+ const pp = a + bb - c;
+ const pa = Math.abs(pp - a), pbb = Math.abs(pp - bb), pc = Math.abs(pp - c);
+ v = (v + (pa <= pbb && pa <= pc ? a : pbb <= pc ? bb : c)) & 0xff;
+ }
+ dst[x] = v;
+ }
+ dst.copy(prev);
+ // careful: prev must hold THIS row for the next iteration
+ dst.copy(prev, 0, 0, stride);
+ }
+ return { width, height, rgba: out };
+};
+
+/** Content bounding box of non-background pixels (background = white). */
+const contentBbox = (img: { width: number; height: number; rgba: Buffer }, bpp: 3 | 4 = 3) => {
+ let minX = img.width, minY = img.height, maxX = -1, maxY = -1;
+ for (let y = 0; y < img.height; y++) {
+ for (let x = 0; x < img.width; x++) {
+ const i = (y * img.width + x) * bpp;
+ const r = img.rgba[i], g = img.rgba[i + 1], bb = img.rgba[i + 2];
+ const alpha = bpp === 4 ? img.rgba[i + 3] : 255;
+ const isBg = alpha < 10 || (r >= 245 && g >= 245 && bb >= 245);
+ if (!isBg) {
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ }
+ }
+ assert.ok(maxX >= 0, 'no content found in image');
+ return { minX, minY, maxX, maxY, w: maxX - minX + 1, h: maxY - minY + 1 };
+};
+
let passed = 0;
-const ok = (name: string, cond: boolean) => {
- assert.equal(cond, true, `FAILED: ${name}`);
+const ok = (name: string, cond: boolean, detail = '') => {
+ assert.equal(cond, true, `FAILED: ${name}${detail ? ` — ${detail}` : ''}`);
passed++;
console.log(`PASS ${name}`);
};
+// ---------------------------------------------------------------------------
+// 0. Master artwork — the supplied single source of truth
+// ---------------------------------------------------------------------------
+const MASTER = 'public/nexdrop-logo-official.png';
+const MASTER_SHA = '161879035dd679485aaf0bb630814089de7cd4399f4db76786ebe0c8849dd963';
+ok('master artwork is byte-identical to the supplied official logo', sha256(MASTER) === MASTER_SHA);
+{
+ const m = pngSize(MASTER);
+ ok('master artwork is the full 1254x1254 master', m.width === 1254 && m.height === 1254);
+}
+
// ---------------------------------------------------------------------------
// 1. Android adaptive launcher foregrounds (all densities)
+// Phase 2.1: the foreground carries the COMPLETE artwork (symbol +
+// wordmark + tagline). Content aspect must match the master's full
+// composition; the rejected symbol-only N-mark cannot pass.
// ---------------------------------------------------------------------------
const densities: [string, number, string][] = [
- ['mdpi', 108, 'bb47fb99fa3e41ac646537a95d318a14b751b2b4463de7834d3207dabcdede3b'],
- ['hdpi', 162, 'ca98efc87f19296156ecd7d4ca2696a682604d56481c15f69a3ab5e94697cc7c'],
- ['xhdpi', 216, '806d36e953d2999d0c0cbef2677ecb0bac0dd0cd2fd364a3c2695b1cf4753012'],
- ['xxhdpi', 324, 'c1a4fd6d15d7367e09a07d1a8ac8b0fbab5d8268799885e1e70f3af06eaa57bb'],
- ['xxxhdpi', 432, '9d3d1d1f7717b79fe2221211764789dd2e7190e7e9bf0bc25e674ebab60480f6'],
+ ['mdpi', 108, '54e460239efd2a5352fc0613c970abf28f881cf245adc0f2ffc856bbc65907dd'],
+ ['hdpi', 162, 'f64a1254755b8c09fc7384c71ff9120e2dc3433afbf4f3fc3e6323021d26305f'],
+ ['xhdpi', 216, '7a463c7953dc4915d61dffddcb66395e02935f856f60a2d29eda899659690660'],
+ ['xxhdpi', 324, 'c1e10cb04d1673ca9282ed3a5ffd90007391e3acffe9130359fded14b5c604b4'],
+ ['xxxhdpi', 432, 'caa7221c054d5738a104905ff7d5a5b2b7e181d6461187fb88589225eb577ba0'],
];
-for (const [d, canvas, hash] of densities) {
+
+for (const [d, canvas, expected] of densities) {
const p = `android/app/src/main/res/drawable-${d}/ic_launcher_foreground.png`;
- const { width, height } = pngSize(p);
- ok(`${d} foreground canvas is ${canvas}x${canvas}`, width === canvas && height === canvas);
- ok(`${d} foreground is the pinned official-artwork resource (old-icon detection)`, sha256(p) === hash);
+ ok(`[${d}] foreground present with official-artwork fingerprint`, sha256(p) === expected);
+ const size = pngSize(p);
+ ok(`[${d}] foreground canvas is ${canvas}x${canvas}`, size.width === canvas && size.height === canvas);
+ const img = decodePng(p);
+ const bb = contentBbox(img, 3);
+ // FULL composition aspect: master content is 963x847 -> h/w ~ 0.8796.
+ // The rejected symbol-only foreground was 963x611 -> h/w ~ 0.634.
+ const ratio = bb.h / bb.w;
+ ok(
+ `[${d}] foreground carries the COMPLETE artwork (aspect ${ratio.toFixed(3)} in full-composition range)`,
+ ratio > 0.84 && ratio < 0.92,
+ `wordmark/tagline missing? content ${bb.w}x${bb.h}`,
+ );
+ const safeRadius = (canvas * (66 / 108)) / 2;
+ const cx = canvas / 2, cy = canvas / 2;
+ const far = Math.hypot(Math.max(bb.maxX - cx, cx - bb.minX), Math.max(bb.maxY - cy, cy - bb.minY));
+ ok(
+ `[${d}] content corners (${far.toFixed(1)}px) inside safe circle (${safeRadius.toFixed(1)}px, +1px raster tolerance)`,
+ far <= safeRadius + 1,
+ );
+ const offX = Math.abs((bb.minX + bb.maxX) / 2 - cx);
+ const offY = Math.abs((bb.minY + bb.maxY) / 2 - cy);
+ ok(`[${d}] content centered (dx ${offX.toFixed(1)}, dy ${offY.toFixed(1)})`, offX <= 2 && offY <= 2);
}
// ---------------------------------------------------------------------------
-// 2. Adaptive-icon wiring: XML references + background color
+// 2. Adaptive icon wiring + background color
// ---------------------------------------------------------------------------
-const adaptiveXml = readFileSync('android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml', 'utf8');
-ok('adaptive icon references the foreground drawable', adaptiveXml.includes('@drawable/ic_launcher_foreground'));
-ok('adaptive icon references the background color', adaptiveXml.includes('@color/ic_launcher_background'));
+const adaptive = readFileSync('android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml', 'utf8');
+ok('adaptive icon references the foreground drawable', adaptive.includes('@drawable/ic_launcher_foreground'));
+ok('adaptive icon references the background color', adaptive.includes('@color/ic_launcher_background'));
const colors = readFileSync('android/app/src/main/res/values/colors.xml', 'utf8');
ok(
- 'launcher background color is the official artwork white (#FFFFFF)',
- /#FFFFFF<\/color>/.test(colors)
+ 'launcher background is the artwork white (#FFFFFF)',
+ /ic_launcher_background[^<]*#FFFFFF/.test(colors),
);
const manifest = readFileSync('android/app/src/main/AndroidManifest.xml', 'utf8');
-ok('manifest icon points at the adaptive mipmap', manifest.includes('android:icon="@mipmap/ic_launcher"'));
+ok('manifest icon is @mipmap/ic_launcher', manifest.includes('android:icon="@mipmap/ic_launcher"'));
ok('manifest label is NexDrop', manifest.includes('android:label="NexDrop"'));
// ---------------------------------------------------------------------------
-// 3. In-app branding drawables (official-artwork derivatives)
+// 3. In-app branding drawables (official artwork derivatives)
// ---------------------------------------------------------------------------
ok(
- 'logo_mark.webp is the pinned official N-mark (in-app header + QR badge)',
+ 'in-app header mark is the official N-mark derivative',
sha256('android/app/src/main/res/drawable-nodpi/logo_mark.webp') ===
- '5f21c03012683825e6773e78890e9b5cae0cd32866e2d12f81a993a288e76ac6'
+ '5f21c03012683825e6773e78890e9b5cae0cd32866e2d12f81a993a288e76ac6',
);
ok(
- 'welcome_hero.webp is the pinned full official artwork (welcome screen)',
+ 'welcome hero carries the complete official artwork',
sha256('android/app/src/main/res/drawable-nodpi/welcome_hero.webp') ===
- '222c7135c2396929916ed9e9eba1ee7599762c627d6bf5ca0545dc8bbf2b8016'
+ '222c7135c2396929916ed9e9eba1ee7599762c627d6bf5ca0545dc8bbf2b8016',
);
// ---------------------------------------------------------------------------
-// 4. PWA icon set + favicon
+// 4. PWA icon set — complete artwork everywhere
// ---------------------------------------------------------------------------
-ok(
- 'master artwork nexdrop-logo-official.png is the pinned single source of truth',
- sha256('public/nexdrop-logo-official.png') === '161879035dd679485aaf0bb630814089de7cd4399f4db76786ebe0c8849dd963'
-);
-const master = pngSize('public/nexdrop-logo-official.png');
-ok('master artwork is the unmodified 1254x1254 official supply', master.width === 1254 && master.height === 1254);
-
-ok('icon-192.png is 192x192 (pinned)', pngSize('public/icons/icon-192.png').width === 192 &&
- sha256('public/icons/icon-192.png') === '3fbc73385dc6495e9f77d91f89f9c703cc9e21f0d33c2d3767e9ede2e36444c0');
-ok('icon-512.png is 512x512 (pinned)', pngSize('public/icons/icon-512.png').width === 512 &&
- sha256('public/icons/icon-512.png') === '3404a12b86e99eed41d853df87d3ba7ea83057428567d450acbf7932f5f41208');
-ok(
- 'icon-maskable-512.png is 512x512 with the safe-circle mark fit (pinned)',
- pngSize('public/icons/icon-maskable-512.png').width === 512 &&
- sha256('public/icons/icon-maskable-512.png') === '45eb53cafd9f0044c47094f86324bddf3a92fbd70a695ef04b81c90860b4ade3'
-);
-ok('apple-touch-icon.png is 180x180 (pinned)', pngSize('public/apple-touch-icon.png').width === 180 &&
- sha256('public/apple-touch-icon.png') === '0679eeb87efdc71f9c158a77492f27d5f1d8b55e65470fb152613a3ce48e73b3');
-
-const favicon = readFileSync('public/icons/icon.svg', 'utf8');
-ok('favicon SVG embeds the official mark raster (never a redrawn vector)', favicon.includes('data:image/png;base64,'));
-ok('favicon SVG carries no external asset reference', !/xlink:href="[^d]/.test(favicon));
+ok('PWA icon-192 is the complete artwork', sha256('public/icons/icon-192.png') ===
+ '071f8c181be48c335c1eb984bfc457f9a8104a0fcbfdd5411515ce3e28a45fae');
+ok('PWA icon-512 is the complete artwork', sha256('public/icons/icon-512.png') ===
+ 'a1dee09c9cd656bf7d3532dc8fd7bfd0c9bca0f8a24c68ce1de3cdbb891624f0');
+ok('apple-touch-icon is the complete artwork', sha256('public/apple-touch-icon.png') ===
+ '19800a65b0db4dde542e745973c7859bd580eb72ad29d8aba5d14084fc44872c');
+{
+ const p = 'public/icons/icon-maskable-512.png';
+ ok('maskable-512 fingerprint', sha256(p) ===
+ '8812534362006d9cafb4a76ae89387d926c3c02320630512a886f5ebb6d2a40e');
+ const size = pngSize(p);
+ ok('maskable-512 canvas', size.width === 512 && size.height === 512);
+ const img = decodePng(p);
+ const bb = contentBbox(img, 3);
+ const ratio = bb.h / bb.w;
+ ok(`maskable carries the COMPLETE artwork (aspect ${ratio.toFixed(3)})`, ratio > 0.84 && ratio < 0.92);
+ const safeRadius = (512 * (66 / 108)) / 2;
+ const far = Math.hypot(Math.max(bb.maxX - 256, 256 - bb.minX), Math.max(bb.maxY - 256, 256 - bb.minY));
+ ok(`maskable content corners (${far.toFixed(1)}px) inside safe circle (${safeRadius.toFixed(1)}px, +1px raster tolerance)`, far <= safeRadius + 1);
+}
+{
+ const svg = readFileSync('public/icons/icon.svg', 'utf8');
+ ok('favicon embeds the official artwork raster (no redrawn vector)',
+ svg.includes('data:image/png;base64,') && !/\.\/|https?:\/\/[^"']+\.png/.test(svg));
+ ok('favicon fingerprint', sha256('public/icons/icon.svg') ===
+ 'a28201cb6b100bcb71463098516845bdcdc7c7ebf2b74d07f4a216f4d8bc9fa2');
+}
// ---------------------------------------------------------------------------
-// 5. Package identity + versioning policy (identity preserved, version bumped)
+// 5. Package identity + version policy
// ---------------------------------------------------------------------------
const gradle = readFileSync('android/app/build.gradle.kts', 'utf8');
-ok('applicationId preserved (com.nexdrop.ndt1)', gradle.includes('applicationId = "com.nexdrop.ndt1"'));
-const vc = Number(/versionCode = (\d+)/.exec(gradle)?.[1] ?? '0');
-ok('versionCode advanced past the 1.4.4 release (17)', vc >= 18);
-const vn = /versionName = "([^"]+)"/.exec(gradle)?.[1];
-ok('versionName present and past 1.4.4', !!vn && vn !== '1.4.4');
+ok('applicationId unchanged (com.nexdrop.ndt1)', gradle.includes('applicationId = "com.nexdrop.ndt1"'));
+const vc = /versionCode = (\d+)/.exec(gradle);
+ok('versionCode >= 19 (1.4.6 release line)', vc !== null && parseInt(vc[1], 10) >= 19);
+const vn = /versionName = "([^"]+)"/.exec(gradle);
+ok('versionName present and past 1.4.5', vn !== null && vn[1] > '1.4.5');
console.log(`\n[branding] ${passed} checks passed`);