#!/usr/bin/env node /* * Emit Android string resources for push-notification fallback text. * * Single source of truth: public/locales/{en,ru}.json under the `Push` * namespace (the same file i18next loads on web). This script takes the * Android-relevant subset and writes values{,-ru}/push_strings.xml. * * Usage: * node scripts/gen-push-strings.mjs # default: android/app/build/generated/res/push/manual/ * node scripts/gen-push-strings.mjs --out # write into /values{,-ru}/ * * The Gradle build calls this with --out into build/generated/res/push//. * The no-arg default writes into the build dir too, so generated XMLs never * appear in src/main/res/. * * Why not parse the JSON at runtime in VojoFirebaseMessagingService? * Because that hot path runs before WebView, before JS, under killed * process — any JSONObject parse failure becomes a missed push. The * review agent's verdict (see §5.27): keep Android on aapt-validated * resources, pay the drift cost at build time, not at push time. */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, '..'); const LOCALES_DIR = path.join(ROOT, 'public', 'locales'); const DEFAULT_OUT = path.join( ROOT, 'android', 'app', 'build', 'generated', 'res', 'push', 'manual' ); // Keys from the Push namespace that end up in Android resources. // SW-only strings (encrypted_message, incoming_call, open_to_answer) are // consumed by the Service Worker at runtime against the JSON bundle — no // need to ship them as Android resources. const ANDROID_KEYS = [ 'new_message', 'new_messages', 'invitation', 'invite_body', 'invite_body_no_room', 'invite_body_no_inviter', 'invite_body_generic', 'missed_call', 'missed_call_body', ]; // i18next uses named placeholders ({{inviter}}); Android string resources // use positional formatters (%1$s). Hardcoding the name→position map here // keeps the Java call signature stable — PushStrings.inviteBody(ctx, // inviter, roomName) always passes inviter in position 1, roomName in // position 2, regardless of how the translators order them in the JSON. // Adding a new placeholder: add it here AND update PushStrings accordingly. // `caller` reuses position 1: it only appears in missed_call_body, which // has no other placeholders, so the position assignment is keyed per-key // in practice — the table just enumerates every placeholder name we accept. const PLACEHOLDER_POSITIONS = { inviter: 1, roomName: 2, caller: 1, }; const LANGS = { en: 'values', ru: 'values-ru', }; function convertPlaceholders(text, locale, key) { const seen = new Set(); const converted = text.replace(/\{\{(\w+)\}\}/g, (_, name) => { const pos = PLACEHOLDER_POSITIONS[name]; if (!pos) { throw new Error( `[${locale}] Push.${key}: unknown placeholder {{${name}}}. ` + `Add it to PLACEHOLDER_POSITIONS in gen-push-strings.mjs ` + `and thread the value through PushStrings.java.` ); } seen.add(name); return `%${pos}$s`; }); return { text: converted, placeholders: seen }; } // Minimal XML escaping for string resources. Single/double quotes must be // backslash-escaped per Android resource rules; ampersand/angle brackets // get entity-encoded. No string in the Push namespace today contains any // of these, but the check has to exist because translators will edit // the source JSON and the script must not silently corrupt their copy. function xmlEscape(text) { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/'/g, "\\'") .replace(/"/g, '\\"'); } function readBundle(locale) { const jsonPath = path.join(LOCALES_DIR, `${locale}.json`); const raw = fs.readFileSync(jsonPath, 'utf8'); const parsed = JSON.parse(raw); const push = parsed?.Push; if (!push || typeof push !== 'object') { throw new Error(`${jsonPath} is missing the "Push" namespace`); } return push; } function verifyParity(bundles) { const locales = Object.keys(bundles); const [first, ...rest] = locales; const firstKeys = new Set(Object.keys(bundles[first])); rest.forEach((locale) => { const keys = new Set(Object.keys(bundles[locale])); const missingInOther = [...firstKeys].filter((k) => !keys.has(k)); const extraInOther = [...keys].filter((k) => !firstKeys.has(k)); if (missingInOther.length || extraInOther.length) { throw new Error( `Push namespace key drift between "${first}" and "${locale}".\n` + ` Missing in ${locale}: ${JSON.stringify(missingInOther)}\n` + ` Extra in ${locale}: ${JSON.stringify(extraInOther)}` ); } }); ANDROID_KEYS.forEach((key) => { locales.forEach((locale) => { if (typeof bundles[locale][key] !== 'string') { throw new Error(`Push.${key} missing or non-string in ${locale}.json`); } }); // Placeholder tokens must match across locales for any given key — // a translator adding {{user}} on one side silently produces // literal-curly-brace output on the other surface. const tokenSets = locales.map((locale) => { const text = bundles[locale][key]; const tokens = new Set(); text.replace(/\{\{(\w+)\}\}/g, (_, name) => { tokens.add(name); return ''; }); return { locale, tokens }; }); const baseline = tokenSets[0]; tokenSets.slice(1).forEach((entry) => { const baselineArr = [...baseline.tokens].sort(); const entryArr = [...entry.tokens].sort(); if (baselineArr.length !== entryArr.length || baselineArr.some((t, i) => t !== entryArr[i])) { throw new Error( `Placeholder drift on Push.${key}: ` + `${baseline.locale}=${JSON.stringify(baselineArr)}, ` + `${entry.locale}=${JSON.stringify(entryArr)}` ); } }); }); } function emitResource(locale, bundle, resDir) { const lines = [ "", '', '', ]; ANDROID_KEYS.forEach((key) => { const raw = bundle[key]; const { text, placeholders } = convertPlaceholders(raw, locale, key); const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : ''; lines.push(` ${xmlEscape(text)}`); }); lines.push(''); lines.push(''); const outPath = path.join(resDir, LANGS[locale], 'push_strings.xml'); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, lines.join('\n'), 'utf8'); return outPath; } function main() { const outIdx = process.argv.indexOf('--out'); if (outIdx !== -1 && !process.argv[outIdx + 1]) { throw new Error('--out requires a directory argument'); } const resDir = outIdx !== -1 ? path.resolve(process.argv[outIdx + 1]) : DEFAULT_OUT; const bundles = Object.keys(LANGS).reduce((acc, locale) => { acc[locale] = readBundle(locale); return acc; }, {}); verifyParity(bundles); Object.keys(LANGS).forEach((locale) => { const outPath = emitResource(locale, bundles[locale], resDir); process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`); }); } try { main(); } catch (err) { process.stderr.write(`gen-push-strings: ${err.message}\n`); process.exit(1); }