// Generate the Android side of the home-screen widgets from // src/widgets/catalog.json. Run from the RN repo root: // node scripts/sync-android-widgets.mjs [--check] // // android/ is committed and hand-maintained (no `expo prebuild`), so the // react-native-android-widget config plugin never runs for it. This script // does the same job: one RNWidgetProvider subclass per widget (the library // uses the class name as the widget name), a provider XML per widget, the // picker labels and descriptions, and the manifest receivers between the // BEGIN/END WIDGETS markers. With --check it only reports drift and exits 1. import { readFile, writeFile, readdir, unlink, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const CATALOG = join(ROOT, 'src', 'widgets', 'catalog.json'); const MAIN = join(ROOT, 'android', 'app', 'src', 'main'); const PACKAGE = 'com.anonymous.bulwarkmobile'; const KOTLIN = join(MAIN, 'java', ...PACKAGE.split('.'), 'widget', 'WidgetProviders.kt'); const XML_DIR = join(MAIN, 'res', 'xml'); const STRINGS = join(MAIN, 'res', 'values', 'widget_strings.xml'); // Optional picker previews: src/widgets/previews/.png is copied to // res/drawable-nodpi and set as the widget's previewImage. const PREVIEWS = join(ROOT, 'src', 'widgets', 'previews'); const PREVIEW_DIR = join(MAIN, 'res', 'drawable-nodpi'); const MANIFEST = join(MAIN, 'AndroidManifest.xml'); const CHECK_ONLY = process.argv.includes('--check'); // Android's sizing table: n cells is roughly 70n - 30 dp in either direction. const dp = (cells) => 70 * cells - 30; // Launchers refresh at most every 30 minutes; the app pushes fresher data // itself whenever it has some (see src/widgets/sync.ts). const UPDATE_PERIOD_MS = 30 * 60 * 1000; const escapeXml = (s) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, "\\'"); const resName = (name) => `widgetprovider_${name.toLowerCase()}`; const hasPreview = (w) => existsSync(join(PREVIEWS, `${w.name}.png`)); const previewRes = (w) => `${w.name.toLowerCase()}_preview`; function providerXml(w) { const preview = hasPreview(w) ? `\n android:previewImage="@drawable/${previewRes(w)}"` : ''; return ` `; } function kotlinFile(widgets) { const classes = widgets.map((w) => `class ${w.name} : RNWidgetProvider()`).join('\n'); return `// Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. // react-native-android-widget names a widget after its provider class, so each // widget needs its own subclass; the JS side (src/widgets/task-handler.tsx) // renders by that name. package ${PACKAGE}.widget import com.reactnativeandroidwidget.RNWidgetProvider ${classes} `; } function stringsFile(widgets) { const lines = widgets.flatMap((w) => [ ` ${escapeXml(w.label)}`, ` ${escapeXml(w.description)}`, ]); return ` ${lines.join('\n')} `; } function manifestBlock(widgets) { const receivers = widgets.map( (w) => ` `, ); return ` ${receivers.join('\n')} `; } function withManifestBlock(manifest, block) { const begin = manifest.indexOf(' '; const end = manifest.indexOf(endMarker); if (begin !== -1 && end !== -1) { return manifest.slice(0, begin) + block + manifest.slice(end + endMarker.length); } const close = manifest.lastIndexOf(' '); if (close === -1) throw new Error('AndroidManifest.xml has no '); return manifest.slice(0, close) + block + '\n' + manifest.slice(close); } const { widgets } = JSON.parse(await readFile(CATALOG, 'utf8')); const names = new Set(); for (const w of widgets) { if (!/^[A-Z][A-Za-z0-9]*Widget$/.test(w.name)) throw new Error(`Bad widget name ${w.name}`); if (names.has(w.name)) throw new Error(`Duplicate widget name ${w.name}`); names.add(w.name); } const outputs = new Map(); outputs.set(KOTLIN, kotlinFile(widgets)); outputs.set(STRINGS, stringsFile(widgets)); for (const w of widgets) outputs.set(join(XML_DIR, `${resName(w.name)}.xml`), providerXml(w)); outputs.set(MANIFEST, withManifestBlock(await readFile(MANIFEST, 'utf8'), manifestBlock(widgets))); // Provider XML for widgets that were removed from the catalog. const stale = existsSync(XML_DIR) ? (await readdir(XML_DIR)) .filter((f) => f.startsWith('widgetprovider_')) .map((f) => join(XML_DIR, f)) .filter((f) => !outputs.has(f)) : []; // Preview images are binary; compare them byte for byte. const binaryOutputs = new Map(); for (const w of widgets) { if (hasPreview(w)) binaryOutputs.set(join(PREVIEW_DIR, `${previewRes(w)}.png`), await readFile(join(PREVIEWS, `${w.name}.png`))); } const stalePreviews = existsSync(PREVIEW_DIR) ? (await readdir(PREVIEW_DIR)) .filter((f) => /widget_preview\.png$/.test(f)) .map((f) => join(PREVIEW_DIR, f)) .filter((f) => !binaryOutputs.has(f)) : []; stale.push(...stalePreviews); let drift = stale.length; for (const [path, bytes] of binaryOutputs) { const current = existsSync(path) ? await readFile(path) : null; if (current && current.equals(bytes)) continue; drift++; if (CHECK_ONLY) { console.log(`out of date: ${path}`); } else { await mkdir(dirname(path), { recursive: true }); await writeFile(path, bytes); console.log(`wrote ${path}`); } } // Checkouts with core.autocrlf have CRLF files: compare without line endings // and write back in whatever the file already uses. const lf = (text) => text.replace(/\r\n/g, '\n'); for (const [path, content] of outputs) { const current = existsSync(path) ? await readFile(path, 'utf8') : null; if (current !== null && lf(current) === lf(content)) continue; drift++; if (CHECK_ONLY) { console.log(`out of date: ${path}`); } else { await mkdir(dirname(path), { recursive: true }); await writeFile(path, current?.includes('\r\n') ? lf(content).replace(/\n/g, '\r\n') : lf(content)); console.log(`wrote ${path}`); } } for (const path of stale) { if (CHECK_ONLY) console.log(`stale: ${path}`); else { await unlink(path); console.log(`removed ${path}`); } } if (CHECK_ONLY && drift > 0) { console.error(`${drift} widget file(s) out of date; run node scripts/sync-android-widgets.mjs`); process.exit(1); } console.log(drift === 0 ? 'Android widget files are up to date.' : `${widgets.length} widgets synced.`);