mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
194 lines
8.1 KiB
JavaScript
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, '&').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 `<?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.`);
|