Files
native/scripts/sync-android-widgets.mjs
T

194 lines
8.1 KiB
JavaScript

// 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/<WidgetName>.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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').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 `<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="${dp(w.cols)}dp"
android:minHeight="${dp(w.rows)}dp"
android:minResizeWidth="${dp(w.minCols ?? w.cols)}dp"
android:minResizeHeight="${dp(w.minRows ?? w.rows)}dp"
android:targetCellWidth="${w.cols}"
android:targetCellHeight="${w.rows}"
android:resizeMode="horizontal|vertical"
android:description="@string/${resName(w.name)}_description"
android:initialLayout="@layout/rn_widget"${preview}
android:updatePeriodMillis="${w.static ? 0 : UPDATE_PERIOD_MS}"
android:widgetCategory="home_screen">
</appwidget-provider>
`;
}
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) => [
` <string name="${resName(w.name)}_label">${escapeXml(w.label)}</string>`,
` <string name="${resName(w.name)}_description">${escapeXml(w.description)}</string>`,
]);
return `<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
<resources>
${lines.join('\n')}
</resources>
`;
}
function manifestBlock(widgets) {
const receivers = widgets.map(
(w) => ` <receiver android:name=".widget.${w.name}" android:exported="false" android:label="@string/${resName(w.name)}_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${PACKAGE}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/${resName(w.name)}"/>
</receiver>`,
);
return ` <!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
${receivers.join('\n')}
<!-- END WIDGETS -->`;
}
function withManifestBlock(manifest, block) {
const begin = manifest.indexOf(' <!-- BEGIN WIDGETS');
const endMarker = '<!-- END WIDGETS -->';
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(' </application>');
if (close === -1) throw new Error('AndroidManifest.xml has no </application>');
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.`);