import { createHash } from 'node:crypto' import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, join, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { inflateSync } from 'node:zlib' const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..') const featureAssetDirectoryRelative = 'docs/v3/play/assets/feature-graphic' const mobileIconManifestRelative = 'docs/v3/play/assets/mobile-icon-manifest.json' const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const androidLegacyDensities = new Map([ ['mdpi', 48], ['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192] ]) const requiredIosSlots = [ ['iphone', '20x20', '2x', 'AppIcon-iphone-20@2x.png', 40], ['iphone', '20x20', '3x', 'AppIcon-iphone-20@3x.png', 60], ['iphone', '29x29', '2x', 'AppIcon-iphone-29@2x.png', 58], ['iphone', '29x29', '3x', 'AppIcon-iphone-29@3x.png', 87], ['iphone', '40x40', '2x', 'AppIcon-iphone-40@2x.png', 80], ['iphone', '40x40', '3x', 'AppIcon-iphone-40@3x.png', 120], ['iphone', '60x60', '2x', 'AppIcon-iphone-60@2x.png', 120], ['iphone', '60x60', '3x', 'AppIcon-iphone-60@3x.png', 180], ['ipad', '20x20', '1x', 'AppIcon-ipad-20@1x.png', 20], ['ipad', '20x20', '2x', 'AppIcon-ipad-20@2x.png', 40], ['ipad', '29x29', '1x', 'AppIcon-ipad-29@1x.png', 29], ['ipad', '29x29', '2x', 'AppIcon-ipad-29@2x.png', 58], ['ipad', '40x40', '1x', 'AppIcon-ipad-40@1x.png', 40], ['ipad', '40x40', '2x', 'AppIcon-ipad-40@2x.png', 80], ['ipad', '76x76', '1x', 'AppIcon-ipad-76@1x.png', 76], ['ipad', '76x76', '2x', 'AppIcon-ipad-76@2x.png', 152], ['ipad', '83.5x83.5', '2x', 'AppIcon-ipad-83.5@2x.png', 167], ['ios-marketing', '1024x1024', '1x', 'AppIcon-marketing-1024@1x.png', 1024] ] function fail(code) { throw new Error(`play_store_asset_verification_failed:${code}`) } function assert(condition, code) { if (!condition) fail(code) } function comparablePath(path) { return process.platform === 'win32' ? path.toLowerCase() : path } function resolveInside(baseDirectory, relativePath, code = 'invalid_asset_path') { assert(typeof relativePath === 'string' && relativePath.length > 0, 'invalid_asset_path') const absolutePath = resolve(baseDirectory, relativePath) const comparableBase = comparablePath(baseDirectory) const comparableAbsolute = comparablePath(absolutePath) assert( comparableAbsolute.startsWith(`${comparableBase}${sep}`), `${code}_outside_allowed_directory` ) return absolutePath } function sha256(buffer) { return createHash('sha256').update(buffer).digest('hex').toUpperCase() } function readRequired(baseDirectory, relativePath, code) { const absolutePath = resolveInside(baseDirectory, relativePath, code) try { const file = readFileSync(absolutePath) assert(statSync(absolutePath).isFile(), `${code}_not_file`) return { absolutePath, file } } catch (error) { if (error?.code === 'ENOENT') fail(`${code}_missing`) throw error } } function readJson(baseDirectory, relativePath, code) { const { file } = readRequired(baseDirectory, relativePath, code) try { return JSON.parse(file.toString('utf8')) } catch { fail(`${code}_invalid_json`) } } function readPngHeader(buffer, filename) { assert(buffer.length >= 33, `truncated_png_${filename}`) assert(buffer.subarray(0, 8).equals(pngSignature), `invalid_png_signature_${filename}`) assert(buffer.readUInt32BE(8) === 13, `invalid_ihdr_length_${filename}`) assert(buffer.subarray(12, 16).toString('ascii') === 'IHDR', `missing_ihdr_${filename}`) return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20), bitDepth: buffer[24], colorType: buffer[25], compression: buffer[26], filter: buffer[27], interlace: buffer[28] } } function parsePng(buffer, filename) { const header = readPngHeader(buffer, filename) const idat = [] let hasTransparencyChunk = false let sawIend = false let offset = 8 while (offset < buffer.length) { assert(offset + 12 <= buffer.length, `truncated_png_chunk_${filename}`) const length = buffer.readUInt32BE(offset) const type = buffer.subarray(offset + 4, offset + 8).toString('ascii') const dataStart = offset + 8 const dataEnd = dataStart + length assert(dataEnd + 4 <= buffer.length, `truncated_png_chunk_${filename}`) if (type === 'IDAT') idat.push(buffer.subarray(dataStart, dataEnd)) if (type === 'tRNS') hasTransparencyChunk = true offset = dataEnd + 4 if (type === 'IEND') { sawIend = true break } } assert(sawIend, `missing_png_iend_${filename}`) return { ...header, idat, hasTransparencyChunk } } function paethPredictor(left, up, upperLeft) { const predictor = left + up - upperLeft const leftDistance = Math.abs(predictor - left) const upDistance = Math.abs(predictor - up) const upperLeftDistance = Math.abs(predictor - upperLeft) if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) return left if (upDistance <= upperLeftDistance) return up return upperLeft } function rgba8AlphaRange(png, filename) { assert(png.bitDepth === 8 && png.colorType === 6, `unsupported_alpha_png_${filename}`) assert(png.compression === 0 && png.filter === 0, `unsupported_png_encoding_${filename}`) assert(png.interlace === 0, `interlaced_png_${filename}`) assert(png.idat.length > 0, `missing_png_idat_${filename}`) const bytesPerPixel = 4 const rowBytes = png.width * bytesPerPixel const inflated = inflateSync(Buffer.concat(png.idat)) assert(inflated.length === (rowBytes + 1) * png.height, `unexpected_png_data_length_${filename}`) let minimum = 255 let maximum = 0 let previousRow = Buffer.alloc(rowBytes) let inputOffset = 0 for (let y = 0; y < png.height; y += 1) { const filterType = inflated[inputOffset] inputOffset += 1 assert(filterType <= 4, `invalid_png_filter_${filename}`) const row = Buffer.allocUnsafe(rowBytes) for (let x = 0; x < rowBytes; x += 1) { const raw = inflated[inputOffset + x] const left = x >= bytesPerPixel ? row[x - bytesPerPixel] : 0 const up = previousRow[x] const upperLeft = x >= bytesPerPixel ? previousRow[x - bytesPerPixel] : 0 let value = raw if (filterType === 1) value += left if (filterType === 2) value += up if (filterType === 3) value += Math.floor((left + up) / 2) if (filterType === 4) value += paethPredictor(left, up, upperLeft) row[x] = value & 0xff } for (let x = 3; x < rowBytes; x += bytesPerPixel) { minimum = Math.min(minimum, row[x]) maximum = Math.max(maximum, row[x]) } inputOffset += rowBytes previousRow = row } return { minimum, maximum } } function verifyPng(baseDirectory, relativePath, expected, code) { const { file } = readRequired(baseDirectory, relativePath, code) assert(sha256(file) === expected.sha256, `${code}_hash`) const png = parsePng(file, basename(relativePath)) assert(png.width === expected.width, `${code}_width`) assert(png.height === expected.height, `${code}_height`) assert(png.bitDepth === expected.bitDepth, `${code}_bit_depth`) assert(png.colorType === expected.colorType, `${code}_color_type`) if (expected.alpha === 'opaque') { if (png.colorType === 6) { const alpha = rgba8AlphaRange(png, basename(relativePath)) assert(alpha.minimum === 255 && alpha.maximum === 255, `${code}_not_opaque`) } else { assert(!png.hasTransparencyChunk, `${code}_transparency_chunk`) } } if (expected.alpha === 'has_transparency') { const alpha = rgba8AlphaRange(png, basename(relativePath)) assert(alpha.minimum < 255 && alpha.maximum === 255, `${code}_transparency_range`) } if (expected.alpha === 'no_alpha_channel') { assert(png.colorType !== 4 && png.colorType !== 6, `${code}_alpha_channel`) assert(!png.hasTransparencyChunk, `${code}_transparency_chunk`) } } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function verifySvg(baseDirectory, asset, code) { const { file } = readRequired(baseDirectory, asset.path, code) assert(sha256(file) === asset.sha256, `${code}_hash`) const text = file.toString('utf8') assert(/\\s*${escapeRegExp(android.background.value)}\\s*`, 'i' ) assert(backgroundPattern.test(colors), 'android_launcher_background') assert( Array.isArray(android.sourceAssets) && android.sourceAssets.length === 2, 'android_source_count' ) android.sourceAssets.forEach((asset, index) => verifySvg(root, asset, `android_source_${index}`)) for (const [role, expectedPath] of [ ['foreground', 'apps/mobile-rn/android/app/src/main/res/drawable/d3ro_launcher_foreground.xml'], ['monochrome', 'apps/mobile-rn/android/app/src/main/res/drawable/d3ro_launcher_monochrome.xml'] ]) { const vector = android.vectors?.[role] assert(vector?.path === expectedPath, `android_${role}_vector_path`) const { file } = readRequired(root, vector.path, `android_${role}_vector`) assert(sha256(file) === vector.sha256, `android_${role}_vector_hash`) const text = file.toString('utf8') assert(//.test(text), 'android_adaptive_icon_background' ) assert( //.test(text), 'android_adaptive_icon_foreground' ) const hasMonochrome = //.test(text) assert(hasMonochrome === adaptive.monochrome, 'android_adaptive_icon_monochrome') assert(adaptive.path.includes(adaptive.monochrome ? '-v33/' : '-v26/'), 'android_adaptive_api') } for (const requiredPath of requiredAdaptivePaths.keys()) { assert( adaptivePaths.has(requiredPath), `android_adaptive_icon_missing_${basename(requiredPath)}` ) } assert( Array.isArray(android.legacyIcons) && android.legacyIcons.length === androidLegacyDensities.size, 'android_legacy_icon_count' ) const densities = new Set() for (const legacy of android.legacyIcons) { const expectedPixels = androidLegacyDensities.get(legacy.density) assert(expectedPixels !== undefined, `android_density_${legacy.density}`) assert(!densities.has(legacy.density), `android_density_duplicate_${legacy.density}`) densities.add(legacy.density) assert(legacy.pixels === expectedPixels, `android_density_pixels_${legacy.density}`) const basePath = `apps/mobile-rn/android/app/src/main/res/mipmap-${legacy.density}` assert(legacy.iconPath === `${basePath}/ic_launcher.png`, `android_icon_path_${legacy.density}`) assert( legacy.roundPath === `${basePath}/ic_launcher_round.png`, `android_round_path_${legacy.density}` ) const expected = { width: expectedPixels, height: expectedPixels, bitDepth: 8, colorType: 6, alpha: 'has_transparency' } verifyPng( root, legacy.iconPath, { ...expected, sha256: legacy.iconSha256 }, `android_icon_${legacy.density}` ) verifyPng( root, legacy.roundPath, { ...expected, sha256: legacy.roundSha256 }, `android_round_icon_${legacy.density}` ) } } function slotKey(icon) { return `${icon.idiom}|${icon.size}|${icon.scale}` } function verifyIosIcons(root, ios) { assert( ios.source === 'docs/v3/play/assets/d3ro-voice-play-icon-source.svg', 'ios_canonical_source' ) assert(JSON.stringify(ios.targetedDeviceFamilies) === '[1,2]', 'ios_targeted_families_contract') assert( ios.bitDepth === 8 && ios.colorType === 2 && ios.alphaChannel === false, 'ios_png_contract' ) assert(Array.isArray(ios.icons) && ios.icons.length === requiredIosSlots.length, 'ios_icon_count') const requiredBySlot = new Map( requiredIosSlots.map(([idiom, size, scale, filename, pixels]) => [ `${idiom}|${size}|${scale}`, { idiom, size, scale, filename, pixels } ]) ) const manifestBySlot = new Map() for (const icon of ios.icons) { const key = slotKey(icon) assert(!manifestBySlot.has(key), `ios_icon_duplicate_${key}`) manifestBySlot.set(key, icon) const required = requiredBySlot.get(key) assert(required, `ios_icon_unexpected_${key}`) assert(icon.filename === required.filename, `ios_icon_filename_${key}`) assert(icon.pixels === required.pixels, `ios_icon_pixels_${key}`) assert( typeof icon.sha256 === 'string' && icon.sha256.length === 64, `ios_icon_hash_contract_${key}` ) } for (const key of requiredBySlot.keys()) { assert(manifestBySlot.has(key), `ios_icon_missing_${key}`) } const contents = readJson(root, ios.contents, 'ios_appicon_contents') assert(contents.info?.author === 'xcode' && contents.info?.version === 1, 'ios_contents_info') assert( Array.isArray(contents.images) && contents.images.length === requiredIosSlots.length, 'ios_contents_count' ) const contentsBySlot = new Map() for (const entry of contents.images) { const key = slotKey(entry) assert(!contentsBySlot.has(key), `ios_contents_duplicate_${key}`) contentsBySlot.set(key, entry) const icon = manifestBySlot.get(key) assert(icon, `ios_contents_unexpected_${key}`) assert(entry.filename === icon.filename, `ios_contents_filename_${key}`) } for (const key of manifestBySlot.keys()) { assert(contentsBySlot.has(key), `ios_contents_missing_${key}`) } const contentsDirectory = dirname(resolveInside(root, ios.contents, 'ios_contents_path')) const expectedPngFiles = new Set(ios.icons.map((icon) => icon.filename)) const actualPngFiles = new Set( readdirSync(contentsDirectory).filter((name) => name.endsWith('.png')) ) assert(actualPngFiles.size === expectedPngFiles.size, 'ios_unassigned_png_count') for (const filename of actualPngFiles) { assert(expectedPngFiles.has(filename), `ios_unassigned_png_${filename}`) } for (const icon of ios.icons) { assert(basename(icon.filename) === icon.filename, `ios_icon_filename_path_${icon.filename}`) verifyPng( contentsDirectory, icon.filename, { width: icon.pixels, height: icon.pixels, bitDepth: ios.bitDepth, colorType: ios.colorType, alpha: 'no_alpha_channel', sha256: icon.sha256 }, `ios_icon_${icon.idiom}_${icon.size}_${icon.scale}` ) } const project = readRequired(root, ios.project, 'ios_xcode_project').file.toString('utf8') assert( (project.match(/ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;/g) ?? []).length >= 2, 'ios_xcode_appicon_binding' ) assert( (project.match(/TARGETED_DEVICE_FAMILY = "1,2";/g) ?? []).length >= 2, 'ios_xcode_targeted_families' ) } function verifyMobileIcons(root) { const manifest = readJson(root, mobileIconManifestRelative, 'mobile_icon_manifest') assert(manifest.schemaVersion === 1, 'mobile_icon_manifest_schema') assert( manifest.canonicalSource?.path === 'docs/v3/play/assets/d3ro-voice-play-icon-source.svg', 'canonical_icon_source_path' ) verifySvg(root, manifest.canonicalSource, 'canonical_icon_source') assert( manifest.playStoreIcon?.path === 'docs/v3/play/assets/d3ro-voice-play-icon-512.png', 'play_icon_path' ) verifyPng(root, manifest.playStoreIcon.path, manifest.playStoreIcon, 'play_icon') verifyAndroidIcons(root, manifest.android) verifyIosIcons(root, manifest.ios) verifyPng( root, manifest.pixelLauncherProof.path, { ...manifest.pixelLauncherProof, bitDepth: 8, colorType: 6, alpha: 'opaque' }, 'pixel_launcher_proof' ) return { androidLegacyIconCount: manifest.android.legacyIcons.length * 2, iosIconCount: manifest.ios.icons.length } } function verifyAll(root, printSummary = true) { const feature = verifyFeatureGraphics(root) const mobile = verifyMobileIcons(root) if (printSummary) { console.log( `Play store assets verified: ${feature.candidateCount} historical feature candidates + canonical ${feature.canonicalId}, canonical 512 icon, ${mobile.androidLegacyIconCount} Android legacy icons + adaptive/monochrome, ${mobile.iosIconCount} iOS/iPad icons, hashes and alpha contracts GREEN` ) } } function copyFixture(relativePath, temporaryRoot) { const source = resolveInside(workspaceRoot, relativePath, 'self_test_source') const destination = resolveInside(temporaryRoot, relativePath, 'self_test_destination') mkdirSync(dirname(destination), { recursive: true }) cpSync(source, destination, { recursive: true }) } function createSelfTestFixture() { const temporaryRoot = mkdtempSync(join(tmpdir(), 'd3ro-mobile-icon-assets-')) for (const relativePath of [ 'docs/v3/play/assets', 'apps/mobile-rn/android/app/src/main/AndroidManifest.xml', 'apps/mobile-rn/android/app/src/main/res', 'apps/mobile-rn/ios/D3ROVoice/Images.xcassets/AppIcon.appiconset', 'apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj' ]) { copyFixture(relativePath, temporaryRoot) } return temporaryRoot } function expectSelfTestFailure(name, mutate, expectedCode) { const temporaryRoot = createSelfTestFixture() try { mutate(temporaryRoot) let failure try { verifyAll(temporaryRoot, false) } catch (error) { failure = error } assert(failure instanceof Error, `self_test_${name}_unexpected_green`) assert( failure.message === `play_store_asset_verification_failed:${expectedCode}`, `self_test_${name}_wrong_failure_${failure.message}` ) console.log(`Negative self-test ${name}: ${expectedCode} RED as expected`) } finally { rmSync(temporaryRoot, { recursive: true, force: true }) } } function runSelfTests() { verifyAll(workspaceRoot, false) expectSelfTestFailure( 'android_manifest_icon', (root) => { const path = resolveInside( root, 'apps/mobile-rn/android/app/src/main/AndroidManifest.xml', 'self_test_android_manifest' ) writeFileSync( path, readFileSync(path, 'utf8').replace('@mipmap/ic_launcher"', '@mipmap/missing"') ) }, 'android_manifest_icon' ) expectSelfTestFailure( 'android_density_dimensions', (root) => { const manifestPath = resolveInside( root, mobileIconManifestRelative, 'self_test_mobile_manifest' ) const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) const hdpi = manifest.android.legacyIcons.find((icon) => icon.density === 'hdpi') const mdpi = manifest.android.legacyIcons.find((icon) => icon.density === 'mdpi') const mdpiBytes = readFileSync(resolveInside(root, mdpi.iconPath, 'self_test_mdpi_icon')) writeFileSync(resolveInside(root, hdpi.iconPath, 'self_test_hdpi_icon'), mdpiBytes) hdpi.iconSha256 = sha256(mdpiBytes) writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) }, 'android_icon_hdpi_width' ) expectSelfTestFailure( 'android_monochrome_missing', (root) => { unlinkSync( resolveInside( root, 'apps/mobile-rn/android/app/src/main/res/drawable/d3ro_launcher_monochrome.xml', 'self_test_monochrome' ) ) }, 'android_monochrome_vector_missing' ) expectSelfTestFailure( 'ios_icon_missing', (root) => { unlinkSync( resolveInside( root, 'apps/mobile-rn/ios/D3ROVoice/Images.xcassets/AppIcon.appiconset/AppIcon-marketing-1024@1x.png', 'self_test_ios_marketing_icon' ) ) }, 'ios_unassigned_png_count' ) expectSelfTestFailure( 'ios_contents_filename', (root) => { const path = resolveInside( root, 'apps/mobile-rn/ios/D3ROVoice/Images.xcassets/AppIcon.appiconset/Contents.json', 'self_test_ios_contents' ) const contents = JSON.parse(readFileSync(path, 'utf8')) contents.images[0].filename = 'missing.png' writeFileSync(path, `${JSON.stringify(contents, null, 2)}\n`) }, 'ios_contents_filename_iphone|20x20|2x' ) expectSelfTestFailure( 'ios_alpha_channel', (root) => { const manifestPath = resolveInside( root, mobileIconManifestRelative, 'self_test_mobile_manifest' ) const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) const icon = manifest.ios.icons.find( (candidate) => candidate.idiom === 'iphone' && candidate.size === '20x20' && candidate.scale === '2x' ) const contentsDirectory = dirname( resolveInside(root, manifest.ios.contents, 'self_test_ios_contents') ) const path = resolveInside(contentsDirectory, icon.filename, 'self_test_ios_icon') const bytes = readFileSync(path) bytes[25] = 6 writeFileSync(path, bytes) icon.sha256 = sha256(bytes) writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) }, 'ios_icon_iphone_20x20_2x_color_type' ) expectSelfTestFailure( 'feature_canonical_hash', (root) => { const manifestPath = resolveInside( root, `${featureAssetDirectoryRelative}/manifest.json`, 'self_test_feature_manifest' ) const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) const path = resolveInside( root, `${featureAssetDirectoryRelative}/${manifest.canonical.output}`, 'self_test_feature_canonical' ) const bytes = readFileSync(path) bytes[bytes.length - 1] ^= 0xff writeFileSync(path, bytes) }, 'feature_canonical_hash' ) console.log('Play/mobile icon negative self-tests GREEN: 7 fail-closed cases') } const argumentsSet = new Set(process.argv.slice(2)) for (const argument of argumentsSet) { assert(argument === '--self-test', `unknown_argument_${argument}`) } if (argumentsSet.has('--self-test')) { runSelfTests() } else { verifyAll(workspaceRoot) }