diff --git a/.changeset/android-ongoing-notification-dismissal.md b/.changeset/android-ongoing-notification-dismissal.md
new file mode 100644
index 00000000..679bad82
--- /dev/null
+++ b/.changeset/android-ongoing-notification-dismissal.md
@@ -0,0 +1,9 @@
+---
+'@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.
+
+`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/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..4c1a2361
--- /dev/null
+++ b/docs/adr/0012-android-ongoing-notification-dismissal-state.md
@@ -0,0 +1,159 @@
+# 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](0011-live-activity-push-state-getters-and-interaction-drain.md)
+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. 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:
+
+- `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. 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
+ 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 |
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}`}
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ value: {
+ color: '#E2E8F0',
+ fontSize: 14,
+ marginTop: 6,
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 8,
+ marginTop: 12,
+ },
+})
diff --git a/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx b/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx
index f6cd5102..33c9ee81 100644
--- a/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx
+++ b/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx
@@ -27,6 +27,8 @@ import { Button } from '~/components/Button'
import { Card } from '~/components/Card'
import { ScreenLayout } from '~/components/ScreenLayout'
+import { AndroidOngoingNotificationHookCard } from './AndroidOngoingNotificationHookCard'
+
type OngoingNotificationStyle = 'progress' | 'bigText' | 'bigPicture' | 'inbox' | 'metric'
const DEFAULT_NOTIFICATION_ID = 'testing-ground-android-ongoing-notification'
@@ -546,6 +548,8 @@ export default function AndroidOngoingNotificationTestingScreen() {
title="Android Ongoing Notifications"
description="Test semantic Android ongoing notification payloads, capability checks, rich progress fields, and action buttons. For remote testing, send a real notification whose data contains the payload shown below."
>
+
+
Rich Progress Fallback
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..5641ad6c 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,40 @@ 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`. `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
+ // 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 +202,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 +241,12 @@ 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). 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,
notificationId = notificationId,
@@ -207,13 +256,16 @@ class VoltraNotificationManager(
val record =
createMergedRecord(
notificationId = notificationId,
- currentRecord = existingRecord,
+ 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)
@@ -313,6 +365,7 @@ class VoltraNotificationManager(
)
notificationManager.cancel(record.systemNotificationId)
removeRecord(notificationId)
+ notifyEnded(notificationId, ENDED_REASON_STOPPED)
return AndroidOngoingNotificationStopResult(
ok = true,
notificationId = notificationId,
@@ -362,6 +415,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..9536cef0 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,114 @@ 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)
+ 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 restart = start(bigTextPayload(text = "Gate 18 to 42"), notificationId = "restart-me")
+
+ assertTrue(restart.ok)
+ assertEquals("started", restart.action)
+ 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)
+ val update =
+ runBlocking {
+ manager.updateOngoingNotification("restart-me", bigTextPayload(text = "Boarding"), null)
+ }
+ 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()
+ 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..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
@@ -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,185 @@ 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('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()
+
+ 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..49a18ee5 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,43 @@ 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(() => {
+ if (Platform.OS !== 'android') {
+ return
+ }
+
+ const clearIfCurrent = (notificationId: string) => {
+ setTargetId((current) => (current === notificationId ? null : current))
+ }
+
+ // 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.
+ 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 +179,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 +191,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..60348d72 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.
+
+`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)
+
+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 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