From 08a27673504984f3ace701418a9b073c5ffeb893 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 13:54:17 +0200 Subject: [PATCH 1/5] docs(adr): record Android ongoing notification state after a dismissal (ADR 0012) --- ...id-ongoing-notification-field-placement.md | 3 +- ...id-ongoing-notification-dismissal-state.md | 145 ++++++++++++++++++ docs/adr/README.md | 1 + 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0012-android-ongoing-notification-dismissal-state.md diff --git a/docs/adr/0010-android-ongoing-notification-field-placement.md b/docs/adr/0010-android-ongoing-notification-field-placement.md index 1374d094..8226f9a4 100644 --- a/docs/adr/0010-android-ongoing-notification-field-placement.md +++ b/docs/adr/0010-android-ongoing-notification-field-placement.md @@ -1,6 +1,7 @@ # ADR 0010: Where Android ongoing notification fields live -Status: Accepted +Status: Accepted. The last consequence, restarting after a timeout, is superseded by +[ADR 0012](0012-android-ongoing-notification-dismissal-state.md). Implemented by [#327](https://github.com/callstackincubator/voltra/pull/327). diff --git a/docs/adr/0012-android-ongoing-notification-dismissal-state.md b/docs/adr/0012-android-ongoing-notification-dismissal-state.md new file mode 100644 index 00000000..898ea4e5 --- /dev/null +++ b/docs/adr/0012-android-ongoing-notification-dismissal-state.md @@ -0,0 +1,145 @@ +# ADR 0012: Android ongoing notification state after a dismissal + +Status: Accepted + +Partially supersedes [ADR 0010](0010-android-ongoing-notification-field-placement.md): its last +consequence ("a timed-out notification ... needs `stop` before it can start again") no longer +holds. + +## Introduction + +On Android 14 and later the user can swipe an ongoing notification away, and a notification posted +with `timeoutMs` is removed by the system when the timeout expires. Voltra records both: the +notification's delete intent reaches `VoltraOngoingNotificationDismissedReceiver`, which marks the +stored record `dismissed`. `getAndroidOngoingNotificationStatus()` then reports +`isActive: false, isDismissed: true`, and updates are rejected with `reason: 'dismissed'`. + +`useAndroidOngoingNotification()` never finds out. Reproduced on an API 35 emulator with the hook +mounted: + +1. After a swipe, or after a 10 s `timeoutMs` expired with the app in the foreground, the hook kept + reporting `isActive: true` indefinitely. Its `isActive` is seeded once from the native status at + mount and afterwards changes only through its own `start()` and `end()`. +2. `start()` resolved `{ ok: false, reason: 'already_exists' }` and posted nothing, because a + dismissed record still counts as existing. +3. After a remount the hook reported `isActive: false`, but `start()` still failed with + `already_exists`. `end()` then returned a client-side `not_found` without calling native code, so + the hook had no way left to post a notification under that id. + +The same `already_exists` applies to `startAndroidOngoingNotification()` called directly. ADR 0010 +recorded that as a consequence: a timed-out notification has to be stopped before it can start +again. + +## Context + +The hook keeps a JavaScript copy of state that the device owns. The device can change that state +without any JavaScript call: + +- **Swipe.** The user dismisses the notification. Pulling the shade down over the app and swiping + there does not change `AppState`, so an `AppState` listener does not cover it. +- **Timeout.** The system removes the notification while the app may be in the foreground, with no + user interaction at all. On the API 35 emulator the delete intent is delivered on timeout, so + this arrives as a dismissal. That is the device check ADR 0010 left open. +- **Another caller.** `stopAndroidOngoingNotification()` or `endAllAndroidOngoingNotifications()` + can run elsewhere in the app, for example in the handler for a remote push with + `operation: 'stop'`. + +A tap does not remove the notification: Voltra never sets `setAutoCancel` (ADR 0010), and action +buttons only open deep links. So there is no tap case to handle. + +The device already persists the state. The record survives process death, so the getter is correct +on a cold start. What is missing is a signal while the app runs. Android has no native-to-JS event +channel today; every value crosses as a Promise result or a synchronous getter. + +## Decision + +### State is the getter plus an internal "ended" event + +This follows the model ADR 0011 sets for iOS: the getter returns the current value, and an event +reports changes. The TurboModule spec gains one codegen event emitter: + +```ts +readonly onOngoingNotificationEnded: CodegenTypes.EventEmitter<{ + notificationId: string + reason: string // 'dismissed' | 'stopped' +}> +``` + +Native code emits it when a record stops being live: + +- `'dismissed'`, from `VoltraNotificationManager.markDismissed`, for a swipe or a timeout (both reach + the delete intent); +- `'stopped'`, from `stopOngoingNotification`, and once per record from `endAllOngoingNotifications`. + +`VoltraNotificationManager` keeps a process-wide set of listeners in its companion object, because +the broadcast receiver has no reference to the TurboModule. `VoltraModule` adds itself in +`initialize()` and removes itself in `invalidate()`. When no module is alive (the process was started +only for the broadcast), nothing is emitted, and the persisted record serves as the getter's answer +on the next launch. + +The event is not queued. A queue is for things that happen once, like interactions in ADR 0011. A +dismissal is a state change, and the record already holds the latest state, so a listener that +missed the event reads the record instead. + +The event is internal. Only the hook subscribes to it. It is not added to `addVoltraListener`, and +no public type changes. Making it public is a separate decision, with its own name and payload +shape. + +### The hook subscribes first, then reads the getter + +`useAndroidOngoingNotification` subscribes once when it mounts. When the event carries the id the +hook is managing, the hook clears that id and `isActive` becomes `false`. After subscribing, it +checks the native status once more, so a dismissal between the first render and the subscription is +not lost. This is the same subscribe-then-read order ADR 0011 documents. + +Rejected: re-syncing on `AppState` changes alone. It misses a swipe made from the shade over the app +and a timeout with the app in the foreground; the reproduction above hit both. Polling was rejected +for the same reason: it is either slow or wasteful, and it is still wrong between polls. + +### `start()` replaces a dismissed record + +`startOngoingNotification` treats an existing record marked `dismissed` as absent. It overwrites the +record with a fresh one: options from the dismissed lifecycle are not carried over, and the +notification is posted again. A record that is not dismissed still produces `already_exists`. + +`upsertAndroidOngoingNotification()` and `updateAndroidOngoingNotification()` keep rejecting a +dismissed record with `reason: 'dismissed'`. The line falls between two intents: + +- `start` is the app saying "begin this now". Something like a new ride or a restarted workout is + a deliberate decision, and it should not depend on whether the user swiped the last one away. +- `update` and `upsert` carry progress, very often from a server push. They must not bring back a + notification the user dismissed. That is the existing contract, and it does not change. + +The check reads the persisted `dismissed` flag, not the live status bar. Right after `notify()` the +status bar may not list the notification yet, so a check against it could let a second, rapid +`start` with the same id post a duplicate. + +### `autoStart` runs when it is turned on, not whenever the hook is inactive + +The hook's `autoStart` effect used to depend on whether the hook was inactive. Once dismissals clear +the hook's state, that dependency, together with the `start()` change above, would re-post a +notification the moment the user swipes it away. It also made `end()` restart the notification +immediately while `autoStart` was set. The effect now runs when the component mounts with `autoStart` +on, or when `autoStart` turns from off to on, and only if the notification is not already active at +that moment. This matches the iOS `useLiveActivity` hook and the documented meaning: "create the +notification when the component mounts". + +So a notification the user dismissed stays dismissed while the component stays mounted. It comes +back on the next mount with `autoStart`, or when the app calls `start()`. + +## Consequences + +- `isActive` from the hook turns `false` when the user swipes the notification away, when it times + out, and when any code in the app stops it. +- `start()`, from the hook or directly, posts again after a dismissal or timeout without calling + `stop` first. Before this change it returned `already_exists`. This is a behavior change and it + ships as a patch. +- Remote updates and upserts still cannot resurrect a dismissed notification. +- `end()` with `autoStart` set no longer restarts the notification straight away. +- Android now has one native-to-JS event, `onOngoingNotificationEnded`, and it is internal. Later + Android events can follow the same pattern: a codegen emitter on the spec, plus a listener set + that the module registers in `initialize()`. +- A notification cancelled by other code through the platform API (for example another library's + "dismiss all notifications") never reaches the delete intent. Voltra cannot observe that case. The + hook keeps reporting `isActive: true` until its next call, and an update then posts the + notification again, as it did before. diff --git a/docs/adr/README.md b/docs/adr/README.md index c2c6cfff..e8c71012 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,3 +32,4 @@ Status values: | [0009](0009-dynamic-widget-localisation.md) | Localisation for Dynamic Widgets and Dynamic Live Activities | Accepted | | [0010](0010-android-ongoing-notification-field-placement.md) | Where Android ongoing notification fields live | Accepted | | [0011](0011-live-activity-push-state-getters-and-interaction-drain.md) | Live Activity push state getters and interaction drain | Accepted | +| [0012](0012-android-ongoing-notification-dismissal-state.md) | Android ongoing notification state after a dismissal | Accepted | From 5370846fda7d9f33423158a19b8c82dceb7a751a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 14:14:48 +0200 Subject: [PATCH 2/5] fix(android-client): reflect swipe-dismissed and timed-out ongoing notifications in the hook Native code now reports when a recorded ongoing notification ends (dismissed by the user or a timeout, or stopped) through an internal TurboModule event, and useAndroidOngoingNotification clears its state when it hears about its notification. start() replaces a dismissed record instead of failing with already_exists, while update and upsert keep refusing to resurrect it. autoStart runs on mount or when it turns on, not whenever the hook becomes inactive. See ADR 0012. --- .../android-ongoing-notification-dismissal.md | 5 + .../src/main/java/voltra/VoltraModule.kt | 13 ++ .../java/voltra/VoltraNotificationManager.kt | 50 ++++++- .../VoltraNotificationManagerTestBase.kt | 91 ++++++++++++ .../src/native/NativeVoltraAndroid.ts | 12 +- ...useAndroidOngoingNotification.node.test.ts | 137 ++++++++++++++++++ .../src/ongoing-notification/api.ts | 37 ++++- .../managing-ongoing-notifications.md | 22 ++- 8 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 .changeset/android-ongoing-notification-dismissal.md diff --git a/.changeset/android-ongoing-notification-dismissal.md b/.changeset/android-ongoing-notification-dismissal.md new file mode 100644 index 00000000..d0091c65 --- /dev/null +++ b/.changeset/android-ongoing-notification-dismissal.md @@ -0,0 +1,5 @@ +--- +'@use-voltra/android-client': patch +--- + +`useAndroidOngoingNotification()` now reports `isActive: false` when the user swipes the notification away, when it times out, or when other code in the app stops it. Before, it kept reporting the notification as active. Starting a notification again after a dismissal now works without stopping it first, including with the same `notificationId`. Updates and upserts still refuse to bring a dismissed notification back. With `autoStart`, the hook posts once on mount and no longer re-posts after a dismissal or after `end()`. diff --git a/packages/android-client/android/src/main/java/voltra/VoltraModule.kt b/packages/android-client/android/src/main/java/voltra/VoltraModule.kt index 5eedabfe..e4a858aa 100644 --- a/packages/android-client/android/src/main/java/voltra/VoltraModule.kt +++ b/packages/android-client/android/src/main/java/voltra/VoltraModule.kt @@ -165,12 +165,25 @@ class VoltraModule( override fun onLowMemory() = Unit } + // Feeds `useAndroidOngoingNotification` the dismissals and stops it cannot observe itself (ADR 0012). + private val ongoingNotificationEndedListener = + OngoingNotificationEndedListener { notificationId, reason -> + emitOnOngoingNotificationEnded( + Arguments.createMap().apply { + putString("notificationId", notificationId) + putString("reason", reason) + }, + ) + } + override fun initialize() { super.initialize() reactApplicationContext.registerComponentCallbacks(configurationCallbacks) + VoltraNotificationManager.addEndedListener(ongoingNotificationEndedListener) } override fun invalidate() { + VoltraNotificationManager.removeEndedListener(ongoingNotificationEndedListener) try { reactApplicationContext.unregisterComponentCallbacks(configurationCallbacks) } catch (e: Exception) { diff --git a/packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt b/packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt index 72809ce5..49fa3bf0 100644 --- a/packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt +++ b/packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt @@ -49,6 +49,7 @@ import voltra.ongoingnotification.buildMetricStyle import voltra.ongoingnotification.canUseMetricStyle import voltra.ongoingnotification.hasPromotedNotificationsPermission import voltra.ongoingnotification.renderMetricFallbackText +import java.util.concurrent.CopyOnWriteArraySet private enum class AndroidOngoingNotificationFallbackBehavior { STANDARD, @@ -116,6 +117,14 @@ data class AndroidOngoingNotificationStopResult( val reason: String? = null, ) +/** Told when a recorded ongoing notification stops being live; see [VoltraNotificationManager.addEndedListener]. */ +fun interface OngoingNotificationEndedListener { + fun onEnded( + notificationId: String, + reason: String, + ) +} + class VoltraNotificationManager( context: Context, ) { @@ -138,6 +147,37 @@ class VoltraNotificationManager( private const val MAX_INBOX_LINES = 6 const val EXTRA_NOTIFICATION_ID = "voltra.extra.NOTIFICATION_ID" + /** The user swiped the notification away, or the system removed it when its timeout expired. */ + const val ENDED_REASON_DISMISSED = "dismissed" + + /** The app stopped the notification, alone or through `endAll`. */ + const val ENDED_REASON_STOPPED = "stopped" + + // Process-wide because the dismissal arrives in a BroadcastReceiver that has no reference + // to the TurboModule; the module registers while it is alive (ADR 0012). + private val endedListeners = CopyOnWriteArraySet() + + fun addEndedListener(listener: OngoingNotificationEndedListener) { + endedListeners.add(listener) + } + + fun removeEndedListener(listener: OngoingNotificationEndedListener) { + endedListeners.remove(listener) + } + + private fun notifyEnded( + notificationId: String, + reason: String, + ) { + endedListeners.forEach { listener -> + try { + listener.onEnded(notificationId, reason) + } catch (error: Exception) { + Log.e(TAG, "Ongoing notification ended listener failed", error) + } + } + } + private val json = Json { ignoreUnknownKeys = true @@ -159,6 +199,7 @@ class VoltraNotificationManager( ) writeRecords(prefs, records) Log.d(TAG, "Marked ongoing notification as dismissed: $notificationId") + notifyEnded(notificationId, ENDED_REASON_DISMISSED) } private fun readRecords( @@ -197,7 +238,10 @@ class VoltraNotificationManager( val parsedPayload = AndroidOngoingNotificationPayloadParser.parseValidated(payload) val notificationId = options.notificationId ?: createGeneratedNotificationId() val existingRecord = getRecord(notificationId) - if (existingRecord != null) { + // A dismissed record only remembers that the user (or a timeout) ended it, so that updates + // and upserts cannot bring it back. An explicit start begins a new lifecycle and replaces + // it, without carrying over any of its options (ADR 0012). + if (existingRecord != null && !existingRecord.dismissed) { return@withContext AndroidOngoingNotificationStartResult( ok = false, notificationId = notificationId, @@ -207,7 +251,7 @@ class VoltraNotificationManager( val record = createMergedRecord( notificationId = notificationId, - currentRecord = existingRecord, + currentRecord = null, options = options, allowMissingChannel = false, ).copy( @@ -313,6 +357,7 @@ class VoltraNotificationManager( ) notificationManager.cancel(record.systemNotificationId) removeRecord(notificationId) + notifyEnded(notificationId, ENDED_REASON_STOPPED) return AndroidOngoingNotificationStopResult( ok = true, notificationId = notificationId, @@ -362,6 +407,7 @@ class VoltraNotificationManager( notificationManager.cancel(record.systemNotificationId) } clearRecords() + records.keys.forEach { notificationId -> notifyEnded(notificationId, ENDED_REASON_STOPPED) } } fun canPostPromotedAndroidNotifications(): Boolean = diff --git a/packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerTestBase.kt b/packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerTestBase.kt index 7a0ac96f..b755eede 100644 --- a/packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerTestBase.kt +++ b/packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerTestBase.kt @@ -383,6 +383,97 @@ abstract class VoltraNotificationManagerTestBase( assertEquals(postedBeforeDismiss, postedNotifications().size) } + @Test + fun upsertDoesNotRepostADismissedNotification() { + startChannel() + start(bigTextPayload(), notificationId = "dismiss-me") + val postedBeforeDismiss = postedNotifications().size + VoltraNotificationManager.markDismissed(context, "dismiss-me") + + val upsertResult = + runBlocking { + manager.upsertOngoingNotification( + bigTextPayload(text = "Gate 18 to 42"), + AndroidOngoingNotificationOptions(notificationId = "dismiss-me", channelId = CHANNEL_ID), + ) + } + + assertFalse(upsertResult.ok) + assertEquals("dismissed", upsertResult.reason) + assertEquals(postedBeforeDismiss, postedNotifications().size) + } + + @Test + fun startReplacesADismissedRecordWithANewLifecycle() { + startChannel() + start(bigTextPayload(), notificationId = "restart-me", requestPromotedOngoing = true) + VoltraNotificationManager.markDismissed(context, "restart-me") + val postedBeforeRestart = postedNotifications().size + + val restart = start(bigTextPayload(text = "Gate 18 to 42"), notificationId = "restart-me") + + assertTrue(restart.ok) + assertEquals("started", restart.action) + assertEquals(postedBeforeRestart + 1, postedNotifications().size) + assertFalse(manager.getOngoingNotificationStatus("restart-me").isDismissed) + // Nothing from the dismissed lifecycle carries over: promotion was requested only there. + assertNull(restart.promotion) + val update = + runBlocking { + manager.updateOngoingNotification("restart-me", bigTextPayload(text = "Boarding"), null) + } + assertTrue(update.ok) + } + + @Test + fun startStillRejectsALiveRecord() { + startChannel() + start(bigTextPayload(), notificationId = "live") + + val second = start(bigTextPayload(), notificationId = "live") + + assertFalse(second.ok) + assertEquals("already_exists", second.reason) + } + + @Test + fun endedListenersHearDismissalsAndStops() { + startChannel() + val ended = mutableListOf>() + val listener = OngoingNotificationEndedListener { id, reason -> ended += id to reason } + VoltraNotificationManager.addEndedListener(listener) + try { + start(bigTextPayload(), notificationId = "swiped") + start(bigTextPayload(), notificationId = "stopped") + start(bigTextPayload(), notificationId = "all-1") + start(bigTextPayload(), notificationId = "all-2") + + VoltraNotificationManager.markDismissed(context, "swiped") + VoltraNotificationManager.markDismissed(context, "never-started") + manager.stopOngoingNotification("stopped") + manager.stopOngoingNotification("never-started") + manager.endAllOngoingNotifications() + + assertEquals( + listOf( + "swiped" to VoltraNotificationManager.ENDED_REASON_DISMISSED, + "stopped" to VoltraNotificationManager.ENDED_REASON_STOPPED, + ), + ended.take(2), + ) + assertEquals( + setOf( + "swiped" to VoltraNotificationManager.ENDED_REASON_STOPPED, + "all-1" to VoltraNotificationManager.ENDED_REASON_STOPPED, + "all-2" to VoltraNotificationManager.ENDED_REASON_STOPPED, + ), + ended.drop(2).toSet(), + ) + } finally { + VoltraNotificationManager.removeEndedListener(listener) + } + } + @Test fun promotionPreCheckMatchesPostReasonsWithoutPostingOrRecording() { startChannel() diff --git a/packages/android-client/src/native/NativeVoltraAndroid.ts b/packages/android-client/src/native/NativeVoltraAndroid.ts index 469476af..fe3cbb9a 100644 --- a/packages/android-client/src/native/NativeVoltraAndroid.ts +++ b/packages/android-client/src/native/NativeVoltraAndroid.ts @@ -1,5 +1,5 @@ import { TurboModuleRegistry } from 'react-native' -import type { TurboModule } from 'react-native' +import type { CodegenTypes, TurboModule } from 'react-native' type PreloadImageOptions = Readonly<{ key: string @@ -129,12 +129,22 @@ type AndroidOngoingNotificationCapabilitiesSpec = Readonly<{ canRequestPromotedOngoing: boolean }> +/** + * A recorded ongoing notification stopped being live: `reason` is 'dismissed' (swiped away or timed + * out) or 'stopped' (stop or endAll). Internal to `useAndroidOngoingNotification` (ADR 0012). + */ +type AndroidOngoingNotificationEndedEvent = Readonly<{ + notificationId: string + reason: string +}> + type RequestPinGlanceAppWidgetOptionsSpec = Readonly<{ previewWidth?: number previewHeight?: number }> export interface Spec extends TurboModule { + readonly onOngoingNotificationEnded: CodegenTypes.EventEmitter startAndroidOngoingNotification( payload: string, options: StartAndroidOngoingNotificationOptionsSpec diff --git a/packages/android-client/src/ongoing-notification/__tests__/useAndroidOngoingNotification.node.test.ts b/packages/android-client/src/ongoing-notification/__tests__/useAndroidOngoingNotification.node.test.ts index 04bf9ae1..2f0c1d35 100644 --- a/packages/android-client/src/ongoing-notification/__tests__/useAndroidOngoingNotification.node.test.ts +++ b/packages/android-client/src/ongoing-notification/__tests__/useAndroidOngoingNotification.node.test.ts @@ -51,6 +51,7 @@ describe('useAndroidOngoingNotification', () => { mockedGetNativeVoltraAndroid.mockReturnValue({ isAndroidOngoingNotificationActive: jest.fn().mockReturnValue(true), updateAndroidOngoingNotification: nativeUpdate, + onOngoingNotificationEnded: jest.fn().mockReturnValue({ remove: jest.fn() }), } as unknown as Spec) }) @@ -80,3 +81,139 @@ describe('useAndroidOngoingNotification', () => { expect(replayedOptions).toMatchObject({ color: '#1E88E5' }) }) }) + +describe('useAndroidOngoingNotification lifecycle', () => { + type EndedEvent = { notificationId: string; reason: string } + + let probeOptions: { autoStart?: boolean; autoUpdate?: boolean } + let isActiveOnDevice: jest.Mock + let nativeStart: jest.Mock + let nativeStop: jest.Mock + let removeSubscription: jest.Mock + let endedListeners: ((event: EndedEvent) => void)[] + + function LifecycleProbe() { + apiRef.current = useAndroidOngoingNotification('Driver is on the way', { + notificationId: 'ride-44', + channelId: 'rides', + ...probeOptions, + }) + return null + } + + const mount = async () => { + let tree!: ReturnType + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create(React.createElement(LifecycleProbe)) + }) + return tree + } + + const emitEnded = async (event: EndedEvent) => { + await ReactTestRenderer.act(async () => { + endedListeners.forEach((listener) => listener(event)) + }) + } + + beforeEach(() => { + apiRef = { current: null } + probeOptions = {} + endedListeners = [] + isActiveOnDevice = jest.fn().mockReturnValue(true) + nativeStart = jest.fn().mockResolvedValue({ ok: true, notificationId: 'ride-44', action: 'started' }) + nativeStop = jest.fn().mockResolvedValue({ ok: true, notificationId: 'ride-44', action: 'stopped' }) + removeSubscription = jest.fn() + mockedGetNativeVoltraAndroid.mockReturnValue({ + isAndroidOngoingNotificationActive: isActiveOnDevice, + startAndroidOngoingNotification: nativeStart, + stopAndroidOngoingNotification: nativeStop, + updateAndroidOngoingNotification: jest.fn().mockResolvedValue({ ok: true, notificationId: 'ride-44' }), + onOngoingNotificationEnded: jest.fn((listener: (event: EndedEvent) => void) => { + endedListeners.push(listener) + return { remove: removeSubscription } + }), + } as unknown as Spec) + }) + + it('turns inactive when the device reports its notification dismissed', async () => { + await mount() + expect(apiRef.current!.isActive).toBe(true) + + await emitEnded({ notificationId: 'another-ride', reason: 'dismissed' }) + expect(apiRef.current!.isActive).toBe(true) + + await emitEnded({ notificationId: 'ride-44', reason: 'dismissed' }) + expect(apiRef.current!.isActive).toBe(false) + }) + + it('turns inactive when its notification is stopped by other code', async () => { + await mount() + + await emitEnded({ notificationId: 'ride-44', reason: 'stopped' }) + + expect(apiRef.current!.isActive).toBe(false) + }) + + it('reads the device again after subscribing, so a dismissal before the subscription is not lost', async () => { + // Active for the first render, dismissed by the time the subscription exists. + isActiveOnDevice.mockReturnValueOnce(true).mockReturnValue(false) + + await mount() + + expect(apiRef.current!.isActive).toBe(false) + }) + + it('starts again after a dismissal', async () => { + await mount() + await emitEnded({ notificationId: 'ride-44', reason: 'dismissed' }) + + await ReactTestRenderer.act(async () => { + await apiRef.current!.start() + }) + + expect(nativeStart).toHaveBeenCalledTimes(1) + expect(apiRef.current!.isActive).toBe(true) + }) + + it('auto-starts once on mount and not again after a dismissal or end()', async () => { + isActiveOnDevice.mockReturnValue(false) + probeOptions = { autoStart: true } + + await mount() + expect(nativeStart).toHaveBeenCalledTimes(1) + expect(apiRef.current!.isActive).toBe(true) + + await emitEnded({ notificationId: 'ride-44', reason: 'dismissed' }) + expect(apiRef.current!.isActive).toBe(false) + expect(nativeStart).toHaveBeenCalledTimes(1) + + await ReactTestRenderer.act(async () => { + await apiRef.current!.start() + }) + await ReactTestRenderer.act(async () => { + await apiRef.current!.end() + }) + expect(nativeStop).toHaveBeenCalledTimes(1) + expect(apiRef.current!.isActive).toBe(false) + expect(nativeStart).toHaveBeenCalledTimes(2) + }) + + it('does not auto-start over a notification that is already active', async () => { + probeOptions = { autoStart: true } + + await mount() + + expect(nativeStart).not.toHaveBeenCalled() + expect(apiRef.current!.isActive).toBe(true) + }) + + it('unsubscribes on unmount', async () => { + const tree = await mount() + + await ReactTestRenderer.act(async () => { + tree.unmount() + }) + + expect(removeSubscription).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/android-client/src/ongoing-notification/api.ts b/packages/android-client/src/ongoing-notification/api.ts index 6603d053..9c16e47d 100644 --- a/packages/android-client/src/ongoing-notification/api.ts +++ b/packages/android-client/src/ongoing-notification/api.ts @@ -76,10 +76,15 @@ export const useAndroidOngoingNotification = ( }) const isActive = targetId !== null + const targetIdRef = useRef(targetId) const optionsRef = useRef(options) const contentRef = useRef(content) const lastUpdateOptionsRef = useRef(undefined) + useEffect(() => { + targetIdRef.current = targetId + }, [targetId]) + useEffect(() => { optionsRef.current = options }, [options]) @@ -90,6 +95,32 @@ export const useAndroidOngoingNotification = ( useUpdateOnHMR() + // The device ends a notification without any call from this hook: the user swipes it away, its + // timeout expires, or other code stops it. Native code reports each of those, so `isActive` follows + // the device rather than only this hook's own calls (ADR 0012). + useEffect(() => { + // Shared components can render this hook on other platforms, where there is no module to listen to. + if (Platform.OS !== 'android') { + return + } + + const clearIfCurrent = (notificationId: string) => { + setTargetId((current) => (current === notificationId ? null : current)) + } + const subscription = getNativeVoltraAndroid().onOngoingNotificationEnded(({ notificationId }) => { + clearIfCurrent(notificationId) + }) + + // Subscribe first, then read: a dismissal that landed after the first render but before the + // subscription existed is only visible through the getter. + const subscribedTargetId = targetIdRef.current + if (subscribedTargetId && !isAndroidOngoingNotificationActive(subscribedTargetId)) { + clearIfCurrent(subscribedTargetId) + } + + return () => subscription.remove() + }, []) + const start = useCallback(async (options?: Partial) => { const startOptions = { ...optionsRef.current, ...options } if (!startOptions.channelId) { @@ -137,8 +168,10 @@ export const useAndroidOngoingNotification = ( return result }, [targetId]) + // Runs when the component mounts with autoStart on, or when autoStart turns on, and not whenever the + // hook becomes inactive: a notification the user dismissed, or that `end()` stopped, stays ended. useEffect(() => { - if (!options.autoStart || targetId) { + if (!options.autoStart || targetIdRef.current) { return } @@ -147,7 +180,7 @@ export const useAndroidOngoingNotification = ( start().catch((error: unknown) => { console.error('[voltra] useAndroidOngoingNotification autoStart failed:', error) }) - }, [options.autoStart, start, targetId]) + }, [options.autoStart, start]) useEffect(() => { if (!options.autoUpdate || !targetId) { diff --git a/website/src/content/docs/android/development/managing-ongoing-notifications.md b/website/src/content/docs/android/development/managing-ongoing-notifications.md index 49d9dec9..b3c104cf 100644 --- a/website/src/content/docs/android/development/managing-ongoing-notifications.md +++ b/website/src/content/docs/android/development/managing-ongoing-notifications.md @@ -321,10 +321,10 @@ Notes on individual options: call rather than posting without a color. - `category` overrides what Voltra derives from your content, and does not change the channel. - `timeoutMs` is applied on every post, so each update restarts the countdown and a notification that - stops being updated eventually disappears on its own. Once the system removes it, - `getAndroidOngoingNotificationStatus()` reports it as no longer active, and starting that - `notificationId` again needs `stopAndroidOngoingNotification()` first, the same as after a swipe. On - Android 7.x and older the value is kept but not applied. + stops being updated eventually disappears on its own. Once the system removes it, Voltra treats it + the same as a swipe: `getAndroidOngoingNotificationStatus()` reports it as dismissed, updates are + rejected, and starting that `notificationId` again posts a new notification. On Android 7.x and + older the value is kept but not applied. - `group` puts the notification in the bundle the system builds from notifications sharing the key. Voltra does not post a group summary of its own. @@ -509,6 +509,18 @@ The hook returns: Use `autoStart` to create the notification when the component mounts, and `autoUpdate` to refresh it when the JSX content changes. +`isActive` follows the device, not only the hook's own calls. It turns `false` when the user swipes the notification away, when it times out (`timeoutMs`), and when anything in your app stops it, for example a push handler calling `stopAndroidOngoingNotification()`. This works even when the user swipes it from the notification shade while your app stays in the foreground. + +A dismissal is respected: `autoStart` posts once when the component mounts, or when you turn `autoStart` on, and does not post again after the user dismisses the notification. Call `start()` when your flow should show the notification again: + +```tsx +const { start, isActive } = useAndroidOngoingNotification(content, options) + +if (!isActive) { + // e.g. render a "Show progress in notifications" button that calls start() +} +``` + ## Action buttons You can add action buttons as children of any layout. @@ -628,7 +640,7 @@ Start, update, and upsert reject with a code instead of posting something broken | `VOLTRA_NOTIFICATION_NOT_PROMOTABLE` | Promotion was requested with `fallbackBehavior: 'error'` and the notification is not eligible | Read the reasons in the message (see below) | | `VOLTRA_NOTIFICATION_INTERNAL_ERROR` | An unexpected device failure | Retry; if it persists, file an issue with the message | -A dismissed notification is different: updates return `ok: false, reason: 'dismissed'` and nothing is re-posted. If your flow legitimately restarts after a dismissal, start a new notification instead of updating. +A dismissed notification is different: updates and upserts return `ok: false, reason: 'dismissed'` and nothing is re-posted, so a remote push cannot bring back a notification the user swiped away. If your flow legitimately restarts after a dismissal, call `startAndroidOngoingNotification()` (or the hook's `start()`). It can reuse the same `notificationId`: the start replaces the dismissed notification with a new one and does not keep any of its options. ## Promoted ongoing notifications From f45122e04d85a2a6a69049ccaa2903448d668aed Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 14:30:24 +0200 Subject: [PATCH 3/5] fix(android-client): keep the system slot when restarting a dismissed notification Address review: a start over a dismissed record reuses its system notification id so a still-visible copy is replaced in place, the hook tolerates a missing native event emitter, and the docs, changeset and ADR spell out the remount and start-from-push consequences. --- .../android-ongoing-notification-dismissal.md | 6 ++- ...id-ongoing-notification-dismissal-state.md | 23 ++++++++-- .../java/voltra/VoltraNotificationManager.kt | 20 +++++--- .../VoltraNotificationManagerTestBase.kt | 21 ++++++++- ...useAndroidOngoingNotification.node.test.ts | 46 +++++++++++++++++++ .../src/ongoing-notification/api.ts | 19 ++++++-- .../managing-ongoing-notifications.md | 4 +- 7 files changed, 120 insertions(+), 19 deletions(-) diff --git a/.changeset/android-ongoing-notification-dismissal.md b/.changeset/android-ongoing-notification-dismissal.md index d0091c65..679bad82 100644 --- a/.changeset/android-ongoing-notification-dismissal.md +++ b/.changeset/android-ongoing-notification-dismissal.md @@ -2,4 +2,8 @@ '@use-voltra/android-client': patch --- -`useAndroidOngoingNotification()` now reports `isActive: false` when the user swipes the notification away, when it times out, or when other code in the app stops it. Before, it kept reporting the notification as active. Starting a notification again after a dismissal now works without stopping it first, including with the same `notificationId`. Updates and upserts still refuse to bring a dismissed notification back. With `autoStart`, the hook posts once on mount and no longer re-posts after a dismissal or after `end()`. +`useAndroidOngoingNotification()` now reports `isActive: false` when the user swipes the notification away, when it times out, or when other code in the app stops it. Before, it kept reporting the notification as active. + +`startAndroidOngoingNotification()` and the hook's `start()` now post again after a dismissal or timeout, including with the same `notificationId`. Before, they returned `already_exists` until the notification was stopped. Updates and upserts still refuse to bring a dismissed notification back. + +With `autoStart`, the hook posts when it mounts or when `autoStart` turns on. It no longer posts again right after a dismissal or after `end()` while the component stays mounted. On the next mount, it posts again. diff --git a/docs/adr/0012-android-ongoing-notification-dismissal-state.md b/docs/adr/0012-android-ongoing-notification-dismissal-state.md index 898ea4e5..776237c6 100644 --- a/docs/adr/0012-android-ongoing-notification-dismissal-state.md +++ b/docs/adr/0012-android-ongoing-notification-dismissal-state.md @@ -55,8 +55,9 @@ channel today; every value crosses as a Promise result or a synchronous getter. ### State is the getter plus an internal "ended" event -This follows the model ADR 0011 sets for iOS: the getter returns the current value, and an event -reports changes. The TurboModule spec gains one codegen event emitter: +This follows the model ADR 0011 sets for iOS (proposed in +[#353](https://github.com/callstackincubator/voltra/pull/353)): the getter returns the current value, +and an event reports changes. The TurboModule spec gains one codegen event emitter: ```ts readonly onOngoingNotificationEnded: CodegenTypes.EventEmitter<{ @@ -100,7 +101,10 @@ for the same reason: it is either slow or wasteful, and it is still wrong betwee `startOngoingNotification` treats an existing record marked `dismissed` as absent. It overwrites the record with a fresh one: options from the dismissed lifecycle are not carried over, and the -notification is posted again. A record that is not dismissed still produces `already_exists`. +notification is posted again. Only the system notification id is kept. If a dismissal was recorded +while the notification was in fact still showing, for example from a stale delete broadcast, the new +post then replaces it in place, so no copy is left behind that `stop` and `endAll` can no longer +reach. A record that is not dismissed still produces `already_exists`. `upsertAndroidOngoingNotification()` and `updateAndroidOngoingNotification()` keep rejecting a dismissed record with `reason: 'dismissed'`. The line falls between two intents: @@ -133,12 +137,23 @@ back on the next mount with `autoStart`, or when the app calls `start()`. out, and when any code in the app stops it. - `start()`, from the hook or directly, posts again after a dismissal or timeout without calling `stop` first. Before this change it returned `already_exists`. This is a behavior change and it - ships as a patch. + ships as a patch. Code that used `start` for routine updates, such as a push handler that falls + back to `update` on `already_exists`, now brings a dismissed notification back. The docs point + such code to `upsert`. +- With `autoStart`, a dismissed notification is posted again the next time the component mounts. + Before, a remount hit `already_exists` and the notification stayed gone until something called + `stop`. - Remote updates and upserts still cannot resurrect a dismissed notification. - `end()` with `autoStart` set no longer restarts the notification straight away. - Android now has one native-to-JS event, `onOngoingNotificationEnded`, and it is internal. Later Android events can follow the same pattern: a codegen emitter on the spec, plus a listener set that the module registers in `initialize()`. +- The event names a notification id, not a lifecycle. If other code stops a notification and + starts it again under the same id while the hook is mounted, the hook hears the stop, reports + `isActive: false`, and does not learn about the new start. It recovers on its own `start()`, + which gets `already_exists` for the live notification and adopts it. A matching "started" event + would fix this, but it would also need to settle which caller owns the content, and that is out of + scope here. - A notification cancelled by other code through the platform API (for example another library's "dismiss all notifications") never reaches the delete intent. Voltra cannot observe that case. The hook keeps reporting `isActive: true` until its next call, and an update then posts the diff --git a/packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt b/packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt index 49fa3bf0..5641ad6c 100644 --- a/packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt +++ b/packages/android-client/android/src/main/java/voltra/VoltraNotificationManager.kt @@ -150,7 +150,10 @@ class VoltraNotificationManager( /** The user swiped the notification away, or the system removed it when its timeout expired. */ const val ENDED_REASON_DISMISSED = "dismissed" - /** The app stopped the notification, alone or through `endAll`. */ + /** + * The app stopped the notification, alone or through `endAll`. `endAll` reports every record it + * removes, including ones already reported as dismissed; listeners must treat both as idempotent. + */ const val ENDED_REASON_STOPPED = "stopped" // Process-wide because the dismissal arrives in a BroadcastReceiver that has no reference @@ -240,7 +243,9 @@ class VoltraNotificationManager( val existingRecord = getRecord(notificationId) // A dismissed record only remembers that the user (or a timeout) ended it, so that updates // and upserts cannot bring it back. An explicit start begins a new lifecycle and replaces - // it, without carrying over any of its options (ADR 0012). + // it, without carrying over any of its options (ADR 0012). It keeps the system slot, so + // if the old notification is somehow still showing, the new one replaces it in place + // instead of leaving an orphan that stop and endAll could no longer reach. if (existingRecord != null && !existingRecord.dismissed) { return@withContext AndroidOngoingNotificationStartResult( ok = false, @@ -254,10 +259,13 @@ class VoltraNotificationManager( currentRecord = null, options = options, allowMissingChannel = false, - ).copy( - active = true, - dismissed = false, - ) + ).let { fresh -> + fresh.copy( + systemNotificationId = existingRecord?.systemNotificationId ?: fresh.systemNotificationId, + active = true, + dismissed = false, + ) + } val promotion = postNotification(record, parsedPayload, onlyAlertOnce = false) saveRecord(record) diff --git a/packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerTestBase.kt b/packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerTestBase.kt index b755eede..9536cef0 100644 --- a/packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerTestBase.kt +++ b/packages/android-client/android/src/test/java/voltra/VoltraNotificationManagerTestBase.kt @@ -407,14 +407,16 @@ abstract class VoltraNotificationManagerTestBase( fun startReplacesADismissedRecordWithANewLifecycle() { startChannel() start(bigTextPayload(), notificationId = "restart-me", requestPromotedOngoing = true) + val systemId = notificationManager.activeNotifications.single().id + // What a swipe does: the system removes the notification, then the delete intent arrives. + notificationManager.cancel(systemId) VoltraNotificationManager.markDismissed(context, "restart-me") - val postedBeforeRestart = postedNotifications().size val restart = start(bigTextPayload(text = "Gate 18 to 42"), notificationId = "restart-me") assertTrue(restart.ok) assertEquals("started", restart.action) - assertEquals(postedBeforeRestart + 1, postedNotifications().size) + assertEquals(listOf(systemId), notificationManager.activeNotifications.map { it.id }) assertFalse(manager.getOngoingNotificationStatus("restart-me").isDismissed) // Nothing from the dismissed lifecycle carries over: promotion was requested only there. assertNull(restart.promotion) @@ -425,6 +427,21 @@ abstract class VoltraNotificationManagerTestBase( assertTrue(update.ok) } + @Test + fun startOverAStillVisibleDismissedRecordReplacesItInPlace() { + startChannel() + start(bigTextPayload(), notificationId = "restart-me") + // A dismissal recorded while the notification is still showing (e.g. a stale delete broadcast). + VoltraNotificationManager.markDismissed(context, "restart-me") + + val restart = start(bigTextPayload(text = "Gate 18 to 42"), notificationId = "restart-me") + + assertTrue(restart.ok) + assertEquals(1, notificationManager.activeNotifications.size) + assertTrue(manager.stopOngoingNotification("restart-me").ok) + assertTrue(notificationManager.activeNotifications.isEmpty()) + } + @Test fun startStillRejectsALiveRecord() { startChannel() diff --git a/packages/android-client/src/ongoing-notification/__tests__/useAndroidOngoingNotification.node.test.ts b/packages/android-client/src/ongoing-notification/__tests__/useAndroidOngoingNotification.node.test.ts index 2f0c1d35..9921fd76 100644 --- a/packages/android-client/src/ongoing-notification/__tests__/useAndroidOngoingNotification.node.test.ts +++ b/packages/android-client/src/ongoing-notification/__tests__/useAndroidOngoingNotification.node.test.ts @@ -207,6 +207,52 @@ describe('useAndroidOngoingNotification lifecycle', () => { expect(apiRef.current!.isActive).toBe(true) }) + it('auto-starts when autoStart turns on after mount', async () => { + isActiveOnDevice.mockReturnValue(false) + const tree = await mount() + expect(nativeStart).not.toHaveBeenCalled() + + probeOptions = { autoStart: true } + await ReactTestRenderer.act(async () => { + tree.update(React.createElement(LifecycleProbe)) + }) + + expect(nativeStart).toHaveBeenCalledTimes(1) + expect(apiRef.current!.isActive).toBe(true) + }) + + it('follows a generated notification id', async () => { + isActiveOnDevice.mockReturnValue(false) + nativeStart.mockResolvedValue({ ok: true, notificationId: 'generated-1', action: 'started' }) + + function GeneratedIdProbe() { + apiRef.current = useAndroidOngoingNotification('Driver is on the way', { channelId: 'rides' }) + return null + } + await ReactTestRenderer.act(async () => { + ReactTestRenderer.create(React.createElement(GeneratedIdProbe)) + }) + await ReactTestRenderer.act(async () => { + await apiRef.current!.start() + }) + expect(apiRef.current!.isActive).toBe(true) + + await emitEnded({ notificationId: 'generated-1', reason: 'dismissed' }) + + expect(apiRef.current!.isActive).toBe(false) + }) + + it('keeps working without the native ended event', async () => { + mockedGetNativeVoltraAndroid.mockReturnValue({ + isAndroidOngoingNotificationActive: isActiveOnDevice, + startAndroidOngoingNotification: nativeStart, + } as unknown as Spec) + + await mount() + + expect(apiRef.current!.isActive).toBe(true) + }) + it('unsubscribes on unmount', async () => { const tree = await mount() diff --git a/packages/android-client/src/ongoing-notification/api.ts b/packages/android-client/src/ongoing-notification/api.ts index 9c16e47d..49a18ee5 100644 --- a/packages/android-client/src/ongoing-notification/api.ts +++ b/packages/android-client/src/ongoing-notification/api.ts @@ -99,7 +99,6 @@ export const useAndroidOngoingNotification = ( // timeout expires, or other code stops it. Native code reports each of those, so `isActive` follows // the device rather than only this hook's own calls (ADR 0012). useEffect(() => { - // Shared components can render this hook on other platforms, where there is no module to listen to. if (Platform.OS !== 'android') { return } @@ -107,9 +106,21 @@ export const useAndroidOngoingNotification = ( const clearIfCurrent = (notificationId: string) => { setTargetId((current) => (current === notificationId ? null : current)) } - const subscription = getNativeVoltraAndroid().onOngoingNotificationEnded(({ notificationId }) => { - clearIfCurrent(notificationId) - }) + + // Mounting the hook never required the native module before; without it (or with a native build + // older than this JS), the hook keeps working from its own calls instead of crashing the screen. + let subscription: { remove: () => void } + try { + const nativeModule = getNativeVoltraAndroid() + if (typeof nativeModule.onOngoingNotificationEnded !== 'function') { + return + } + subscription = nativeModule.onOngoingNotificationEnded(({ notificationId }) => { + clearIfCurrent(notificationId) + }) + } catch { + return + } // Subscribe first, then read: a dismissal that landed after the first render but before the // subscription existed is only visible through the getter. diff --git a/website/src/content/docs/android/development/managing-ongoing-notifications.md b/website/src/content/docs/android/development/managing-ongoing-notifications.md index b3c104cf..60348d72 100644 --- a/website/src/content/docs/android/development/managing-ongoing-notifications.md +++ b/website/src/content/docs/android/development/managing-ongoing-notifications.md @@ -511,7 +511,7 @@ Use `autoStart` to create the notification when the component mounts, and `autoU `isActive` follows the device, not only the hook's own calls. It turns `false` when the user swipes the notification away, when it times out (`timeoutMs`), and when anything in your app stops it, for example a push handler calling `stopAndroidOngoingNotification()`. This works even when the user swipes it from the notification shade while your app stays in the foreground. -A dismissal is respected: `autoStart` posts once when the component mounts, or when you turn `autoStart` on, and does not post again after the user dismisses the notification. Call `start()` when your flow should show the notification again: +`autoStart` posts when the component mounts, or when you turn `autoStart` on. It does not post again while the component stays mounted, so a notification the user dismisses stays dismissed. The next time the component mounts with `autoStart`, for example when the user comes back to the screen, it is posted again. Call `start()` when your flow should show the notification again sooner: ```tsx const { start, isActive } = useAndroidOngoingNotification(content, options) @@ -640,7 +640,7 @@ Start, update, and upsert reject with a code instead of posting something broken | `VOLTRA_NOTIFICATION_NOT_PROMOTABLE` | Promotion was requested with `fallbackBehavior: 'error'` and the notification is not eligible | Read the reasons in the message (see below) | | `VOLTRA_NOTIFICATION_INTERNAL_ERROR` | An unexpected device failure | Retry; if it persists, file an issue with the message | -A dismissed notification is different: updates and upserts return `ok: false, reason: 'dismissed'` and nothing is re-posted, so a remote push cannot bring back a notification the user swiped away. If your flow legitimately restarts after a dismissal, call `startAndroidOngoingNotification()` (or the hook's `start()`). It can reuse the same `notificationId`: the start replaces the dismissed notification with a new one and does not keep any of its options. +A dismissed notification is different: updates and upserts return `ok: false, reason: 'dismissed'` and nothing is re-posted, so a remote push handled with `upsertAndroidOngoingNotification()` cannot bring back a notification the user swiped away. If your flow legitimately restarts after a dismissal, call `startAndroidOngoingNotification()` (or the hook's `start()`). It can reuse the same `notificationId`: the start replaces the dismissed notification with a new one and does not keep any of its options. Because a start always posts, don't use it to apply routine updates, for example by calling it from a push handler and falling back to an update on `already_exists`, or a dismissed notification will come back. ## Promoted ongoing notifications From f70716d534e1147d6f393b360a76d7a1ef119417 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 15 Oct 2026 14:45:48 +0200 Subject: [PATCH 4/5] chore(example): add a hook card to the ongoing notification screen Shows useAndroidOngoingNotification's isActive next to the native status, with start, end, autoStart and a 10-second timeout, to check the hook against swipes, timeouts and cold starts on a device. --- .../AndroidOngoingNotificationHookCard.tsx | 102 ++++++++++++++++++ ...ndroidOngoingNotificationTestingScreen.tsx | 4 + 2 files changed, 106 insertions(+) create mode 100644 example/screens/testing-grounds/AndroidOngoingNotificationHookCard.tsx diff --git a/example/screens/testing-grounds/AndroidOngoingNotificationHookCard.tsx b/example/screens/testing-grounds/AndroidOngoingNotificationHookCard.tsx new file mode 100644 index 00000000..54eacd21 --- /dev/null +++ b/example/screens/testing-grounds/AndroidOngoingNotificationHookCard.tsx @@ -0,0 +1,102 @@ +import React, { useMemo, useState } from 'react' +import { StyleSheet, Text, View } from 'react-native' +import { AndroidOngoingNotification } from '@use-voltra/android' +import { getAndroidOngoingNotificationStatus, useAndroidOngoingNotification } from '@use-voltra/android-client' + +import { Button } from '~/components/Button' +import { Card } from '~/components/Card' + +const HOOK_NOTIFICATION_ID = 'hook-demo' +const HOOK_TIMEOUT_MS = 10_000 + +const formatResult = (result: { ok: boolean; action?: string; reason?: string }) => + result.ok ? `ok (${result.action})` : `not ok (${result.reason})` + +/** + * Drives `useAndroidOngoingNotification` directly, so the hook's own `isActive` can be compared with + * what the device reports after the notification is swiped away, times out, or is stopped elsewhere. + */ +export function AndroidOngoingNotificationHookCard({ channelId }: { channelId: string }) { + const [count, setCount] = useState(0) + const [autoStart, setAutoStart] = useState(false) + const [timeoutEnabled, setTimeoutEnabled] = useState(false) + const [lastResult, setLastResult] = useState('none') + const [nativeStatus, setNativeStatus] = useState(() => getAndroidOngoingNotificationStatus(HOOK_NOTIFICATION_ID)) + + const content = useMemo( + () => , + [count] + ) + + const { start, update, end, isActive } = useAndroidOngoingNotification(content, { + notificationId: HOOK_NOTIFICATION_ID, + channelId, + autoStart, + autoUpdate: true, + timeoutMs: timeoutEnabled ? HOOK_TIMEOUT_MS : undefined, + }) + + const run = async (label: string, action: () => Promise<{ ok: boolean; action?: string; reason?: string }>) => { + try { + setLastResult(`${label}: ${formatResult(await action())}`) + } catch (error) { + setLastResult(`${label}: ${error instanceof Error ? error.message : String(error)}`) + } + setNativeStatus(getAndroidOngoingNotificationStatus(HOOK_NOTIFICATION_ID)) + } + + return ( + + Hook + {`useAndroidOngoingNotification for "${HOOK_NOTIFICATION_ID}".`} + {`Hook isActive: ${isActive ? 'yes' : 'no'}`} + + {`Native status: isActive=${nativeStatus.isActive} isDismissed=${nativeStatus.isDismissed}`} + + {`Last result: ${lastResult}`} + +