From b76301ca2527984ff2d1e6f811d4e0ae803a4eaa Mon Sep 17 00:00:00 2001 From: Hristo Totov Date: Sat, 24 Oct 2026 10:59:58 +0300 Subject: [PATCH] feat(android): foreground service and versioned push messages for ongoing notifications --- ...ongoing-notification-foreground-service.md | 10 + ...ongoing-notification-foreground-service.md | 137 ++++ docs/adr/README.md | 1 + example/app.json | 3 + ...traAndroidOngoingNotificationBackground.ts | 272 +------- ...ndroidOngoingNotificationTestingScreen.tsx | 33 +- .../src/main/java/voltra/VoltraModule.kt | 24 +- .../java/voltra/VoltraNotificationManager.kt | 579 +++++++++++++++-- ...traOngoingNotificationDismissedReceiver.kt | 22 + .../java/voltra/VoltraRNBridgeExtensions.kt | 35 + ...oidOngoingNotificationForegroundService.kt | 346 ++++++++++ .../AndroidOngoingNotificationPayload.kt | 5 + .../VoltraNotificationException.kt | 1 + .../VoltraOngoingNotificationService.kt | 251 ++++++++ .../VoltraOngoingNotifications.kt | 29 + .../VoltraNotificationManagerTestBase.kt | 12 +- .../OngoingNotificationTestSupport.kt | 85 +++ ...otificationManagerForegroundServiceTest.kt | 598 ++++++++++++++++++ .../VoltraOngoingNotificationServiceTest.kt | 304 +++++++++ .../src/android/foregroundService.ts | 213 +++++++ .../expo-plugin/src/android/index.ts | 4 +- .../src/android/manifest.node.test.ts | 119 ++++ .../expo-plugin/src/android/manifest.ts | 12 +- .../android-client/expo-plugin/src/index.ts | 3 + .../android-client/expo-plugin/src/types.ts | 23 + .../expo-plugin/src/validation.node.test.ts | 48 ++ .../expo-plugin/src/validation.ts | 14 + packages/android-client/src/index.ts | 18 + .../src/native/NativeVoltraAndroid.ts | 40 +- .../__tests__/options.node.test.ts | 65 ++ .../__tests__/push.node.test.ts | 212 +++++++ ...useAndroidOngoingNotification.node.test.ts | 29 + .../src/ongoing-notification/api.ts | 37 +- .../src/ongoing-notification/options.ts | 62 ++ .../src/ongoing-notification/push.ts | 86 +++ packages/android-server/src/index.ts | 13 + packages/android-server/test/index.test.js | 56 ++ packages/android/src/index.ts | 19 + .../android/src/ongoing-notification/push.ts | 188 ++++++ .../android/src/ongoing-notification/types.ts | 90 ++- packages/android/src/server.ts | 19 + packages/cli/src/config/foregroundService.ts | 213 +++++++ .../cli/src/config/normalize.node.test.ts | 79 ++- packages/cli/src/config/normalize.ts | 36 +- packages/cli/src/config/types.ts | 25 + .../platforms/android/manifest.node.test.ts | 147 +++++ .../cli/src/platforms/android/manifest.ts | 29 + packages/cli/test/cli.test.js | 13 + .../android-ongoing-notifications.md | 13 +- skills/voltra/references/plugin-schema.md | 1 + .../docs/android/api/plugin-configuration.md | 47 ++ .../managing-ongoing-notifications.md | 84 ++- .../remote-ongoing-notifications.md | 107 ++-- 53 files changed, 4511 insertions(+), 400 deletions(-) create mode 100644 .changeset/android-ongoing-notification-foreground-service.md create mode 100644 docs/adr/0013-android-ongoing-notification-foreground-service.md create mode 100644 packages/android-client/android/src/main/java/voltra/ongoingnotification/AndroidOngoingNotificationForegroundService.kt create mode 100644 packages/android-client/android/src/main/java/voltra/ongoingnotification/VoltraOngoingNotificationService.kt create mode 100644 packages/android-client/android/src/main/java/voltra/ongoingnotification/VoltraOngoingNotifications.kt create mode 100644 packages/android-client/android/src/test/java/voltra/ongoingnotification/OngoingNotificationTestSupport.kt create mode 100644 packages/android-client/android/src/test/java/voltra/ongoingnotification/VoltraNotificationManagerForegroundServiceTest.kt create mode 100644 packages/android-client/android/src/test/java/voltra/ongoingnotification/VoltraOngoingNotificationServiceTest.kt create mode 100644 packages/android-client/expo-plugin/src/android/foregroundService.ts create mode 100644 packages/android-client/src/ongoing-notification/__tests__/push.node.test.ts create mode 100644 packages/android-client/src/ongoing-notification/push.ts create mode 100644 packages/android/src/ongoing-notification/push.ts create mode 100644 packages/cli/src/config/foregroundService.ts create mode 100644 packages/cli/src/platforms/android/manifest.node.test.ts diff --git a/.changeset/android-ongoing-notification-foreground-service.md b/.changeset/android-ongoing-notification-foreground-service.md new file mode 100644 index 000000000..1bc03aa4a --- /dev/null +++ b/.changeset/android-ongoing-notification-foreground-service.md @@ -0,0 +1,10 @@ +--- +'@use-voltra/android': minor +'@use-voltra/android-client': minor +'@use-voltra/android-server': minor +'voltra': minor +--- + +Android ongoing notifications can now keep the app alive with a foreground service. Declare the types you need with the new `ongoingNotificationForegroundService` config key (Expo plugin and `voltra apply`), then pass `foregroundService: { type }` when you start or upsert a notification. The result tells you whether the service attached and, if not, why. `getAndroidOngoingNotificationCapabilities()` now reports `isUserDismissible` (Android 14+ lets users swipe any ongoing notification away), `supportsForegroundService` and `foregroundServiceTypes`, and `getAndroidOngoingNotificationHandle()` lets an app's own foreground service carry the notification. + +Remote updates get a versioned push message. Build it with `createAndroidOngoingNotificationPushEnvelope()` on the server and apply it with `handleAndroidOngoingNotificationPush()` in the background task, which replaces the hand-written task body. Send a growing `revision` and the device drops messages that arrive out of order (`reason: 'stale_revision'`), including a late stop. `stopAndroidOngoingNotification()` takes an optional `{ revision }`. diff --git a/docs/adr/0013-android-ongoing-notification-foreground-service.md b/docs/adr/0013-android-ongoing-notification-foreground-service.md new file mode 100644 index 000000000..d49fafe43 --- /dev/null +++ b/docs/adr/0013-android-ongoing-notification-foreground-service.md @@ -0,0 +1,137 @@ +# ADR 0013: Ongoing notification foreground service and push envelope + +Status: Accepted + +Implements [#323](https://github.com/callstackincubator/voltra/issues/323). + +## Introduction + +Android ongoing notifications are Voltra's counterpart of iOS Live Activities, but their delivery +stops at `NotificationManager.notify()`. Three problems follow from that: + +1. Nothing keeps the app process alive. A few minutes after the user leaves the app the system may + kill it, and the notification stays in the shade frozen at its last value. +2. On Android 14 and later the user can swipe any `setOngoing(true)` notification away. Neither the + capabilities API nor the docs said so. +3. Every app wrote the same push task body: find the message, parse it, call upsert or stop. The + message had no version, and two pushes delivered out of order left the older state on screen. + +An app that already ran its own foreground service also could not adopt Voltra's notification, +because neither the system notification id nor the built `Notification` reached it. + +## Decision + +### One Voltra-owned foreground service, opt-in per notification + +Voltra ships `voltra.ongoingnotification.VoltraOngoingNotificationService`. The config plugin (and +`voltra apply`) declares it only when `ongoingNotificationForegroundService.types` is set, together +with `FOREGROUND_SERVICE` and the `FOREGROUND_SERVICE_` permissions. The app picks the types, +because it is the app that has to justify each type in Play Console. Voltra does not add runtime +prerequisites such as location permissions, and rejects `camera`, `microphone`, `mediaProjection`, +`phoneCall`, `mediaProcessing` and `systemExempted`, which need setup Voltra cannot provide. + +Removing the key, or dropping a type, removes or narrows the service but never removes a permission. +A library merged in at build time may declare a service that needs the same permission, and the +CLI cannot see it, so a permission Voltra once added cannot be told apart from one the app needs. + +`start` and `upsert` take `foregroundService: { type, onDismiss?, fallbackBehavior? }`: + +- The notification is posted with `notify()` first and its record saved, and then the service is + started with `ACTION_ATTACH`. The manager hands the service the `Notification` it just posted + through an in-process registry, so the service calls `startForeground` with the record's system + id at once, without decoding the payload's images again on the main thread. It rebuilds from the + stored payload only if that hand-over is missing. The manager waits up to 5 s for the outcome; on + a timeout it also queues a stop, so a late attach cannot outlive the result the caller got. +- Problems Voltra can see beforehand are reported without starting the service: `not_declared`, + `unsupported_api` (a type newer than the running API), `type_not_declared`, `busy`, and on API 34+ + `missing_permission` when none of the runtime permissions the type requires is granted (location, + health and connected-device permissions) and `background_start_not_allowed` for `location` from + the background without background location access. +- These checks matter beyond a clear error: a service started with `startForegroundService` that + stops before calling `startForeground` crashes the app on Android 9+. The service therefore never + stops while it owes that call. When its notification is already gone (stopped while the service + was starting) it goes foreground with a bare placeholder, removes it and stops. A `startForeground` + that throws anyway can no longer be satisfied; the checks above exist to make that unreachable for + the documented causes. +- Start failures are mapped from the exception class name (the classes exist only from API 31 and + 34): `background_start_not_allowed`, `timeout_exhausted`, `missing_permission`, + `type_not_declared`. +- After `startForeground` the service checks that the record still exists. If the manager gave up, + or the app stopped the notification meanwhile, the service releases it instead of holding a + notification `stop` and `endAll` could no longer reach. +- With the default `fallbackBehavior: 'standard'` the notification stays posted and the result says + why the service did not attach. With `'error'`, a failure known beforehand rejects with + `VOLTRA_FOREGROUND_SERVICE_UNAVAILABLE` before anything is posted, so the user never sees or hears + a notification that is taken down again. A failure while attaching cancels the notification, + removes the record, keeps its revision as a tombstone, and rejects. +- `update` never touches the service. It reposts under the same id, which the service keeps. An + `upsert` with `foregroundService` that lands on a running notification attaches the service if + that notification is not backed by it yet: in a push flow the first push may be refused from the + background, and a later high-priority push must still be able to attach. +- Every stop command finishes with `stopSelfResult(startId)`, so a stop that races a newer attach + (a dismissal followed by an immediate restart) does not tear the service down under it. +- `stop` and `endAll` stop the service with `STOP_FOREGROUND_REMOVE` and wait for `onDestroy` + before `cancel()`, because `cancel()` is ignored for a foreground service notification. +- A dismissal on API 34+ stops the service with `STOP_FOREGROUND_DETACH` by default (`onDismiss: +'stop'`), or leaves it running (`'keep'`). Nothing is reposted. +- `onTimeout` (API 34 `shortService`, API 35 `dataSync`) detaches the service and records + `timed_out`; the notification stays. + +One service holds one foreground notification. Supporting several would need one generated service +class per notification, so a second request reports `busy`. + +The service returns `START_NOT_STICKY`. After process death there is no JavaScript to drive the +notification, so the system must not recreate the service. The service runs in the app process, +so an in-process registry is enough to know which notification is attached. A fresh process finds +the registry empty and rewrites any record still marked `attached` to `detached`. + +The service calls `Service.startForeground(id, notification, type)` directly behind an API 29 check, +and the two-argument form below. `ServiceCompat.startForeground` would need androidx.core 1.12, and +the library pins 1.9.0 to match `work-runtime-ktx`. + +### Rejected: making notifications non-dismissible again + +On API 34 only call, media and device-policy notifications stay non-dismissible, and a foreground +service does not change that. Reposting after a swipe, or dressing the notification up as +`CallStyle` or `MediaStyle`, would fight the platform and the Live Updates guidance ("Don't repost +Live Updates that the user dismissed"). Voltra reports `isUserDismissible` instead. + +### Handle for apps with their own service + +`getAndroidOngoingNotificationHandle(id)` returns the system notification id, and +`VoltraOngoingNotifications.buildNotification(context, id)` rebuilds the notification in Kotlin, so +an app's own service can call `startForeground` with it. Voltra's `stop` then removes only the +record; the app's own `stopForeground(STOP_FOREGROUND_REMOVE)` removes the notification. + +### Push envelope version 1 and a native revision check + +The push message gets a version (`v`, absent means 1) and an optional `revision`. +`parseAndroidOngoingNotificationPush` is pure and lives in `@use-voltra/android`, so the server's +`createAndroidOngoingNotificationPushEnvelope` and the client's `handleAndroidOngoingNotificationPush` +share one schema. The handler never throws, so a malformed push cannot crash the task. + +The revision check is native, not in JavaScript: each record stores the highest revision applied, +and start, update, upsert and stop with a revision that is not higher return `stale_revision`. A +stop removes the record, so its last revision is kept as a tombstone (newest 100); otherwise an +older upsert arriving after a newer stop would bring the notification back. Storing it natively +makes the check survive the process restarts between pushes. + +Voltra still does not register with FCM, read push tokens, or create channels. The handler takes an +`ensureChannel` callback for the last of these. + +## Consequences + +- Apps can keep the process alive for one ongoing notification at a time, at the cost of a Play + Console declaration for every type they use. +- Records gain `lastRevision` and three foreground service fields. All are optional with defaults, + so records written by older releases still decode. +- The last posted payload is stored under its own key per notification, so the handle's builder and + the service can rebuild a notification. It is kept out of the shared records entry because + payloads can carry inline images and every post would otherwise rewrite all of them. +- The hook's options take no `revision`: they are replayed by every update, so a stored revision + would make all but the first update stale. +- The manager's records lock moves from the instance to the class, so the dismissed receiver and + the service no longer race the manager when they write records. +- `stopAndroidOngoingNotification` takes an optional `{ revision }`. +- The events for dismissal and foreground service state changes are left to the ongoing-notification + events issue; this ADR only persists the state they would report. diff --git a/docs/adr/README.md b/docs/adr/README.md index e8c710129..933406c78 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,3 +33,4 @@ Status values: | [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 | +| [0013](0013-android-ongoing-notification-foreground-service.md) | Ongoing notification foreground service and push envelope | Accepted | diff --git a/example/app.json b/example/app.json index d37e8a49b..d80d6ff22 100644 --- a/example/app.json +++ b/example/app.json @@ -114,6 +114,9 @@ "@use-voltra/android-client", { "enableNotifications": true, + "ongoingNotificationForegroundService": { + "types": ["dataSync", "shortService"] + }, "widgets": [ { "id": "voltra", diff --git a/example/notifications/voltraAndroidOngoingNotificationBackground.ts b/example/notifications/voltraAndroidOngoingNotificationBackground.ts index b4509caa9..76d8b0733 100644 --- a/example/notifications/voltraAndroidOngoingNotificationBackground.ts +++ b/example/notifications/voltraAndroidOngoingNotificationBackground.ts @@ -1,233 +1,14 @@ import * as Notifications from 'expo-notifications' import { Platform } from 'react-native' -import type { - AndroidOngoingNotificationCategory, - AndroidOngoingNotificationPayload, - AndroidOngoingNotificationPresentationOptions, - AndroidOngoingNotificationVisibility, -} from '@use-voltra/android' -import type { UpsertAndroidOngoingNotificationOptions } from '@use-voltra/android-client' -import { stopAndroidOngoingNotification, upsertAndroidOngoingNotification } from '@use-voltra/android-client' +import { handleAndroidOngoingNotificationPush } from '@use-voltra/android-client' export const VOLTRA_BACKGROUND_NOTIFICATION_TASK = 'voltra-background-notification-task' const DEFAULT_CHANNEL_ID = 'voltra_live_updates' -type VoltraOngoingNotificationOperation = 'upsert' | 'stop' - -type VoltraOngoingNotificationMessage = { - notificationId?: unknown - payload?: unknown - operation?: unknown - options?: unknown - channelId?: unknown - smallIcon?: unknown - deepLinkUrl?: unknown - requestPromotedOngoing?: unknown - fallbackBehavior?: unknown -} - type NotificationTaskPayloadData = { data?: unknown } -const isRecord = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null -} - -const parseJsonString = (value: string): unknown => { - try { - return JSON.parse(value) - } catch { - return null - } -} - -const getVoltraMessageCandidate = (value: unknown): Record | null => { - if (typeof value === 'string') { - const parsed = parseJsonString(value) - return isRecord(parsed) ? parsed : null - } - - return isRecord(value) ? value : null -} - -const extractNestedDataRecord = (value: unknown): Record | null => { - if (!isRecord(value)) { - return null - } - - if (isRecord(value.data)) { - return value.data - } - - if (typeof value.dataString === 'string') { - const parsed = parseJsonString(value.dataString) - return isRecord(parsed) ? parsed : null - } - - if (typeof value.body === 'string') { - const parsed = parseJsonString(value.body) - return isRecord(parsed) ? parsed : null - } - - return value -} - -const getTaskPayloadData = (data: unknown): unknown => { - if (!isRecord(data)) { - return data - } - - const nestedData = extractNestedDataRecord(data) - if (nestedData) { - return nestedData - } - - return data -} - -const getMessageFromData = (data: unknown): VoltraOngoingNotificationMessage | null => { - const payloadData = getTaskPayloadData(data) - if (!isRecord(payloadData)) { - return null - } - - const directCandidate = getVoltraMessageCandidate(payloadData.voltraOngoingNotification) - if (directCandidate) { - return directCandidate as VoltraOngoingNotificationMessage - } - - const bodyCandidate = getVoltraMessageCandidate(payloadData.body) - if (bodyCandidate?.voltraOngoingNotification) { - return getVoltraMessageCandidate(bodyCandidate.voltraOngoingNotification) as VoltraOngoingNotificationMessage | null - } - - const dataStringCandidate = getVoltraMessageCandidate(payloadData.dataString) - if (dataStringCandidate?.voltraOngoingNotification) { - return getVoltraMessageCandidate( - dataStringCandidate.voltraOngoingNotification - ) as VoltraOngoingNotificationMessage | null - } - - return null -} - -const parseOperation = (value: unknown): VoltraOngoingNotificationOperation => { - return value === 'stop' ? 'stop' : 'upsert' -} - -const parsePayload = (value: unknown): AndroidOngoingNotificationPayload | string | null => { - if (typeof value === 'string') { - const parsed = parseJsonString(value) - return parsed ? (parsed as AndroidOngoingNotificationPayload) : value - } - - return value ? (value as AndroidOngoingNotificationPayload) : null -} - -const parseString = (value: unknown): string | undefined => { - return typeof value === 'string' && value ? value : undefined -} - -const parseBoolean = (value: unknown): boolean | undefined => { - return typeof value === 'boolean' ? value : undefined -} - -const parseClearableString = (value: unknown): string | null | undefined => (value === null ? null : parseString(value)) - -const parseClearableBoolean = (value: unknown): boolean | null | undefined => - value === null ? null : parseBoolean(value) - -const parseClearableNumber = (value: unknown): number | null | undefined => - value === null ? null : typeof value === 'number' && Number.isFinite(value) ? value : undefined - -type PushPresentationOptions = Pick< - UpsertAndroidOngoingNotificationOptions, - keyof AndroidOngoingNotificationPresentationOptions -> - -/** - * Presentation options are read from the push `options` object only. They are app policy rather than - * content, so a server that only renders what the notification says leaves them out and the device - * keeps what the running notification already uses. A key sent as `null` clears the stored value - * when the push updates an existing notification. - */ -const readPresentationOptions = (rawOptions: unknown): PushPresentationOptions => { - if (!isRecord(rawOptions)) { - return {} - } - - return { - visibility: parseClearableString(rawOptions.visibility) as AndroidOngoingNotificationVisibility | null | undefined, - color: parseClearableString(rawOptions.color), - category: parseClearableString(rawOptions.category) as AndroidOngoingNotificationCategory | null | undefined, - timeoutMs: parseClearableNumber(rawOptions.timeoutMs), - localOnly: parseClearableBoolean(rawOptions.localOnly), - group: parseClearableString(rawOptions.group), - sortKey: parseClearableString(rawOptions.sortKey), - allowSystemGeneratedContextualActions: parseClearableBoolean(rawOptions.allowSystemGeneratedContextualActions), - } -} - -const parseOptions = ( - message: VoltraOngoingNotificationMessage -): UpsertAndroidOngoingNotificationOptions | undefined => { - const rawOptions = isRecord(message.options) - ? message.options - : typeof message.options === 'string' - ? parseJsonString(message.options) - : null - - const presentationOptions = readPresentationOptions(rawOptions) - - const mergedOptions = { - ...(isRecord(rawOptions) ? rawOptions : {}), - channelId: parseString(message.channelId) ?? (isRecord(rawOptions) ? parseString(rawOptions.channelId) : undefined), - smallIcon: parseString(message.smallIcon) ?? (isRecord(rawOptions) ? parseString(rawOptions.smallIcon) : undefined), - deepLinkUrl: - parseString(message.deepLinkUrl) ?? (isRecord(rawOptions) ? parseString(rawOptions.deepLinkUrl) : undefined), - requestPromotedOngoing: - parseBoolean(message.requestPromotedOngoing) ?? - (isRecord(rawOptions) ? parseBoolean(rawOptions.requestPromotedOngoing) : undefined), - fallbackBehavior: - parseString(message.fallbackBehavior) ?? - (isRecord(rawOptions) ? parseString(rawOptions.fallbackBehavior) : undefined), - alert: isRecord(rawOptions) ? parseBoolean(rawOptions.alert) : undefined, - } - - const options: UpsertAndroidOngoingNotificationOptions = { - channelId: DEFAULT_CHANNEL_ID, - ...presentationOptions, - } - - if (mergedOptions.channelId !== undefined) { - options.channelId = mergedOptions.channelId - } - - if (mergedOptions.smallIcon !== undefined) { - options.smallIcon = mergedOptions.smallIcon - } - - if (mergedOptions.deepLinkUrl !== undefined) { - options.deepLinkUrl = mergedOptions.deepLinkUrl - } - - if (mergedOptions.requestPromotedOngoing !== undefined) { - options.requestPromotedOngoing = mergedOptions.requestPromotedOngoing - } - - if (mergedOptions.fallbackBehavior !== undefined) { - options.fallbackBehavior = - mergedOptions.fallbackBehavior as UpsertAndroidOngoingNotificationOptions['fallbackBehavior'] - } - - if (mergedOptions.alert !== undefined) { - options.alert = mergedOptions.alert - } - - return options -} - export const ensureOngoingNotificationChannel = async (channelId = DEFAULT_CHANNEL_ID) => { if (Platform.OS !== 'android') { return @@ -243,50 +24,13 @@ export const ensureOngoingNotificationChannel = async (channelId = DEFAULT_CHANN }) } -export const processVoltraOngoingNotificationMessage = async (data: unknown) => { - if (Platform.OS !== 'android') { - return { processed: false, reason: 'unsupported_platform' as const } - } - - const message = getMessageFromData(data) - if (!message) { - console.log('[voltra-background-task] Ignored task: no voltraOngoingNotification payload.') - return { processed: false, reason: 'missing_message' as const } - } - - const notificationId = typeof message.notificationId === 'string' ? message.notificationId : null - if (!notificationId) { - console.log('[voltra-background-task] Ignored task: missing notificationId.') - return { processed: false, reason: 'missing_notification_id' as const } - } - - const options = parseOptions(message) - const channelId = options?.channelId ?? DEFAULT_CHANNEL_ID - await ensureOngoingNotificationChannel(channelId) - - const operation = parseOperation(message.operation) - if (operation === 'stop') { - const result = await stopAndroidOngoingNotification(notificationId) - console.log('[voltra-background-task] Stop ongoing notification result:', result) - return { processed: result.ok, notificationId, result } - } - - const payload = parsePayload(message.payload) - if (!payload) { - console.log('[voltra-background-task] Ignored task: missing payload for ongoing notification:', notificationId) - return { processed: false, notificationId, reason: 'missing_payload' as const } - } - - const result = await upsertAndroidOngoingNotification(payload, { ...options, notificationId }) - console.log('[voltra-background-task] Upsert ongoing notification result:', result) - return { processed: result.ok, notificationId, result } -} +export const processVoltraOngoingNotificationMessage = (data: unknown) => + handleAndroidOngoingNotificationPush(data, { + defaults: { channelId: DEFAULT_CHANNEL_ID }, + ensureChannel: ensureOngoingNotificationChannel, + }) export const handleBackgroundNotificationTask = async ({ data }: NotificationTaskPayloadData) => { - try { - await processVoltraOngoingNotificationMessage(data) - } catch (error) { - console.log('[voltra-background-task] Handler failed:', error) - throw error - } + const result = await processVoltraOngoingNotificationMessage(data) + console.log('[voltra-background-task] Ongoing notification push result:', result) } diff --git a/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx b/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx index 33c9ee81c..1e012cc62 100644 --- a/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx +++ b/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx @@ -120,6 +120,8 @@ export default function AndroidOngoingNotificationTestingScreen() { const [channelId, setChannelId] = useState('voltra_live_updates') const [smallIcon, setSmallIcon] = useState('') const [requestPromotedOngoing, setRequestPromotedOngoing] = useState(true) + // Backs the notification with Voltra's foreground service (declared for dataSync in app.json). + const [useForegroundService, setUseForegroundService] = useState(false) const [visibility, setVisibility] = useState('unset') const [color, setColor] = useState('') const [category, setCategory] = useState('') @@ -382,21 +384,27 @@ export default function AndroidOngoingNotificationTestingScreen() { channelId, smallIcon: smallIcon || undefined, requestPromotedOngoing, + ...(useForegroundService ? { foregroundService: { type: 'dataSync' as const } } : {}), ...presentationOptions, }) const buildVoltraPushEnvelope = (operation: 'upsert' | 'stop' = 'upsert') => { const data: { voltraOngoingNotification: { + v: 1 notificationId: string operation: 'upsert' | 'stop' + revision: number options: UpsertAndroidOngoingNotificationOptions payload?: AndroidOngoingNotificationPayload } } = { voltraOngoingNotification: { + v: 1, notificationId, operation, + // Any value that grows between pushes works; a backend would send its own version number. + revision: Date.now(), options: { channelId, smallIcon: smallIcon || undefined, @@ -446,7 +454,13 @@ export default function AndroidOngoingNotificationTestingScreen() { result.ok ? `Started Android ongoing notification "${result.notificationId}"${formatStyleFallback( result.styleFallback - )}.` + )}${ + result.foregroundService + ? result.foregroundService.attached + ? ', backed by the foreground service' + : `, without the foreground service (${result.foregroundService.reason})` + : '' + }.` : `Did not start Android ongoing notification "${result.notificationId}": ${result.reason}.` ) setRenderedPayload(renderAndroidOngoingNotificationPayload(content)) @@ -597,6 +611,14 @@ export default function AndroidOngoingNotificationTestingScreen() { {`Can request promoted ongoing: ${ capabilities.canRequestPromotedOngoing ? 'yes' : 'no' }`} + {`User can swipe ongoing notifications away: ${ + capabilities.isUserDismissible ? 'yes' : 'no' + }`} + {`Foreground service: ${ + capabilities.supportsForegroundService + ? `declared (${capabilities.foregroundServiceTypes.join(', ') || 'no types below Android 10'})` + : 'not declared' + }`}