diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index bdf73289..8106099a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -74,6 +74,7 @@
+
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt
index 5df02288..683ff9aa 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt
@@ -14,6 +14,7 @@ class BulwarkFcmPackage : ReactPackage {
add(BulwarkUnifiedPushModule(reactContext))
add(BulwarkClientCertModule(reactContext))
add(BulwarkDeviceSyncModule(reactContext))
+ add(BulwarkWidgetsModule(reactContext))
// The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetRefreshService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetRefreshService.kt
new file mode 100644
index 00000000..eeee2dba
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetRefreshService.kt
@@ -0,0 +1,20 @@
+package com.anonymous.bulwarkmobile
+
+import android.content.Intent
+import com.facebook.react.HeadlessJsTaskService
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.jstasks.HeadlessJsTaskConfig
+
+/**
+ * Runs the widgets' refresh (BulwarkWidgetRefresh, index.ts) as a headless
+ * task when the app goes to the background. Run from the app itself the
+ * refresh stalled: React Native pauses JS timers while the activity is in
+ * the background, and fetch resolves every response through a timer, so the
+ * first request went out and its answer was never read. A running headless
+ * task keeps timers going, and the service keeps the process from being
+ * frozen before the widgets are redrawn.
+ */
+class BulwarkWidgetRefreshService : HeadlessJsTaskService() {
+ override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig =
+ HeadlessJsTaskConfig("BulwarkWidgetRefresh", Arguments.createMap(), 30000L, true)
+}
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetsModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetsModule.kt
new file mode 100644
index 00000000..528d823e
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetsModule.kt
@@ -0,0 +1,28 @@
+package com.anonymous.bulwarkmobile
+
+import android.content.Intent
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReactMethod
+
+/** JS side: src/widgets/sync.ts. */
+class BulwarkWidgetsModule(reactContext: ReactApplicationContext) :
+ ReactContextBaseJavaModule(reactContext) {
+
+ override fun getName(): String = "BulwarkWidgets"
+
+ /** Starts BulwarkWidgetRefreshService; resolves false when Android refuses the start. */
+ @ReactMethod
+ fun refreshInBackground(promise: Promise) {
+ try {
+ reactApplicationContext.startService(Intent(reactApplicationContext, BulwarkWidgetRefreshService::class.java))
+ promise.resolve(true)
+ } catch (e: IllegalStateException) {
+ // Background start limits (the app left the foreground too long ago).
+ promise.resolve(false)
+ } catch (e: SecurityException) {
+ promise.resolve(false)
+ }
+ }
+}
diff --git a/index.ts b/index.ts
index c40fc96b..7db96696 100644
--- a/index.ts
+++ b/index.ts
@@ -26,6 +26,8 @@ AppRegistry.registerHeadlessTask('BulwarkDeviceSync', () => async (data: import(
// Home-screen widgets (Android): every widget event runs this headless task.
registerWidgetTaskHandler(widgetTaskHandler);
+// ...and leaving the app refreshes them in one (BulwarkWidgetRefreshService).
+AppRegistry.registerHeadlessTask('BulwarkWidgetRefresh', () => refreshWidgetsInBackground);
// A headless push task evaluates this file too, so App is required when the
// UI first renders instead of imported above: importing it here would load
diff --git a/src/widgets/__tests__/sync.test.ts b/src/widgets/__tests__/sync.test.ts
index 83b0049a..074b3e82 100644
--- a/src/widgets/__tests__/sync.test.ts
+++ b/src/widgets/__tests__/sync.test.ts
@@ -1,5 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
+const native = vi.hoisted(() => ({ BulwarkWidgets: undefined as undefined | { refreshInBackground: () => Promise } }));
+vi.mock('react-native', () => ({ Platform: { OS: 'android' }, AppState: {}, NativeModules: native }));
+
const refreshSnapshot = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock('../build', () => ({ refreshSnapshot, readRegistry: vi.fn() }));
vi.mock('../render', () => ({ hasPlacedWidgets: vi.fn(async () => true), redrawAll: vi.fn(async () => undefined) }));
@@ -23,6 +26,23 @@ describe('widget sync', () => {
expect(refreshSnapshot).toHaveBeenCalledTimes(1);
});
+ it('hands the refresh to a headless task where the app has one', async () => {
+ vi.useFakeTimers();
+ const refreshInBackground = vi.fn(async () => true);
+ native.BulwarkWidgets = { refreshInBackground };
+ handleAppState('background');
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ expect(refreshInBackground).toHaveBeenCalledTimes(1);
+ expect(refreshSnapshot).not.toHaveBeenCalled();
+
+ // Android refused to start the service: refresh in place after all.
+ refreshInBackground.mockResolvedValueOnce(false);
+ handleAppState('background');
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ expect(refreshSnapshot).toHaveBeenCalledTimes(1);
+ native.BulwarkWidgets = undefined;
+ });
+
it('leaves the app coming back to the scheduled refreshes', async () => {
vi.useFakeTimers();
handleAppState('active');
diff --git a/src/widgets/sync.ts b/src/widgets/sync.ts
index f72c36bc..863c35c5 100644
--- a/src/widgets/sync.ts
+++ b/src/widgets/sync.ts
@@ -4,7 +4,7 @@
// changed: mail or calendar data, the theme or clock settings, and when the
// app goes to the background (the most likely moment to glance at a widget).
-import { AppState, Platform } from 'react-native';
+import { AppState, NativeModules, Platform } from 'react-native';
import { readRegistry, refreshSnapshot } from './build';
import { hasPlacedWidgets, redrawAll } from './render';
import { emptySnapshot } from './snapshot';
@@ -21,7 +21,8 @@ async function run(): Promise {
lastRun = Date.now();
try {
if (!(await hasPlacedWidgets())) return;
- await refreshSnapshot();
+ // Something changed since a refresh already running started; join the next one.
+ await refreshSnapshot({ after: 'change' });
await redrawAll();
} catch (err) {
console.warn('[widgets] sync failed', err);
@@ -37,14 +38,22 @@ function schedule(delayMs: number, urgent = false): void {
/**
* Leaving the app is the most likely moment to glance at a widget, so the
- * widgets refresh right then. Not through a timer: React Native pauses JS
- * timers while the app is in the background, so even a zero-delay timer
- * would only fire when the app comes back.
+ * widgets refresh right then, in a headless task of their own
+ * (BulwarkWidgetRefreshService). React Native pauses JS timers while the app
+ * is in the background, and fetch hands every response over through a timer,
+ * so a refresh run by the backgrounded app itself stops at its first request;
+ * a running headless task keeps timers going.
*/
export function handleAppState(next: string): void {
if (next !== 'background') return;
if (timer) clearTimeout(timer);
- void run();
+ timer = null;
+ const native = NativeModules.BulwarkWidgets as { refreshInBackground?: () => Promise } | undefined;
+ void (native?.refreshInBackground?.() ?? Promise.resolve(false))
+ .catch(() => false)
+ .then((started) => {
+ if (!started) void run();
+ });
}
/** Start following app state; returns the cleanup. No-op off Android. */
@@ -99,7 +108,8 @@ export async function refreshWidgetsInBackground(): Promise {
if (Platform.OS !== 'android') return;
try {
if (!(await hasPlacedWidgets(0))) return;
- await refreshSnapshot();
+ // Something changed since a refresh already running started; join the next one.
+ await refreshSnapshot({ after: 'change' });
await redrawAll();
} catch (err) {
console.warn('[widgets] background refresh failed', err);