From c5143975db0c6a6424dd4801a066b6c9b18bc669 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 2 Oct 2026 12:01:31 +0200
Subject: [PATCH] fix: finish the widget refresh after leaving the app #52
The previous fix started the refresh as the app went to the background,
but it still stopped after its first request: fetch hands every response
over through a JS timer, and React Native pauses timers while the app's
activity is in the background, so the answer was only read once the app
came back. The widgets kept the previous account after a switch and
Home, and changes made in the app reached them late.
The refresh now runs as a headless task of its own,
BulwarkWidgetRefresh, started through BulwarkWidgetRefreshService. A
running headless task keeps the timers going, and the service keeps the
process from being frozen before the widgets are redrawn. Where Android
refuses to start the service the app refreshes in place as before. A
refresh started for a change also no longer joins one that began before
it.
---
android/app/src/main/AndroidManifest.xml | 1 +
.../bulwarkmobile/BulwarkFcmPackage.kt | 1 +
.../BulwarkWidgetRefreshService.kt | 20 +++++++++++++
.../bulwarkmobile/BulwarkWidgetsModule.kt | 28 +++++++++++++++++++
index.ts | 2 ++
src/widgets/__tests__/sync.test.ts | 20 +++++++++++++
src/widgets/sync.ts | 24 +++++++++++-----
7 files changed, 89 insertions(+), 7 deletions(-)
create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetRefreshService.kt
create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetsModule.kt
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);