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}`} + +