Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/android-ongoing-notification-dismissal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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).

Expand Down
159 changes: 159 additions & 0 deletions docs/adr/0012-android-ongoing-notification-dismissal-state.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
102 changes: 102 additions & 0 deletions example/screens/testing-grounds/AndroidOngoingNotificationHookCard.tsx
Original file line number Diff line number Diff line change
@@ -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(
() => <AndroidOngoingNotification.Progress title="Hook demo" text={`Count ${count}`} value={count % 10} max={10} />,
[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 (
<Card>
<Card.Title>Hook</Card.Title>
<Card.Text>{`useAndroidOngoingNotification for "${HOOK_NOTIFICATION_ID}".`}</Card.Text>
<Text testID="hook-is-active" style={styles.value}>{`Hook isActive: ${isActive ? 'yes' : 'no'}`}</Text>
<Text testID="hook-native-status" style={styles.value}>
{`Native status: isActive=${nativeStatus.isActive} isDismissed=${nativeStatus.isDismissed}`}
</Text>
<Text testID="hook-last-result" style={styles.value}>{`Last result: ${lastResult}`}</Text>
<View style={styles.buttonRow}>
<Button testID="hook-start" title="Hook Start" onPress={() => run('start', () => start())} />
<Button testID="hook-count" title="Hook Count +1" variant="secondary" onPress={() => setCount((c) => c + 1)} />
<Button testID="hook-end" title="Hook End" variant="ghost" onPress={() => run('end', end)} />
<Button
testID="hook-auto-start"
title={`Hook autoStart ${autoStart ? 'ON' : 'OFF'}`}
variant="secondary"
onPress={() => setAutoStart((current) => !current)}
/>
<Button
testID="hook-refresh-native"
title="Read Native Status"
variant="secondary"
onPress={() => setNativeStatus(getAndroidOngoingNotificationStatus(HOOK_NOTIFICATION_ID))}
/>
<Button
testID="hook-timeout"
title={`Hook timeout 10s ${timeoutEnabled ? 'ON' : 'OFF'}`}
variant="secondary"
onPress={() => setTimeoutEnabled((current) => !current)}
/>
<Button
testID="hook-update"
title="Hook Update"
variant="secondary"
onPress={() => run('update', () => update())}
/>
</View>
</Card>
)
}

const styles = StyleSheet.create({
value: {
color: '#E2E8F0',
fontSize: 14,
marginTop: 6,
},
buttonRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 12,
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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."
>
<AndroidOngoingNotificationHookCard channelId={channelId} />

<Card>
<Card.Title>Rich Progress Fallback</Card.Title>
<Card.Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading