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);