From fabc4c417ffff777726b620990c1b0f68dda12bf Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 10 Apr 2026 11:55:54 +0200 Subject: [PATCH 1/3] feat: add Android ongoing notification support Add the Android ongoing notification flow across the example app, runtime APIs, and server rendering so apps can render, update, and remotely manage rich ongoing notifications. This also documents the server-side payload renderer and removes the example Firebase Android config from the Expo app config. --- example/app.json | 34 +- .../android-ongoing-notification.tsx | 7 + example/eas.json | 21 + example/index.js | 8 + .../registerBackgroundNotifications.ts | 34 + example/notifications/registerPushLogging.ts | 61 ++ ...traAndroidOngoingNotificationBackground.ts | 243 ++++++ example/package.json | 3 +- example/screens/android/AndroidScreen.tsx | 11 +- .../BasicAndroidLiveUpdate.tsx | 97 --- .../BasicAndroidOngoingNotification.tsx | 71 ++ example/screens/live-activities/types.ts | 2 +- ...ndroidOngoingNotificationTestingScreen.tsx | 758 +++++++++++++++++ package-lock.json | 20 + packages/android-server/README.md | 18 +- packages/android-server/src/index.ts | 64 +- packages/android/src/VoltraModule.ts | 60 +- packages/android/src/client.ts | 54 +- packages/android/src/events.ts | 38 +- packages/android/src/index.ts | 27 + packages/android/src/live-update/api.ts | 370 ++++++--- .../android/src/live-update/components.tsx | 49 ++ packages/android/src/live-update/renderer.ts | 378 ++++++++- packages/android/src/live-update/types.ts | 271 ++++-- packages/android/src/server.ts | 18 +- packages/expo-plugin/src/android/index.ts | 4 +- packages/expo-plugin/src/android/manifest.ts | 43 +- packages/expo-plugin/src/index.ts | 1 + packages/expo-plugin/src/types.ts | 6 + packages/expo-plugin/src/validation.ts | 4 + .../android/src/main/AndroidManifest.xml | 4 +- .../src/main/java/voltra/VoltraModule.kt | 143 +++- .../java/voltra/VoltraNotificationManager.kt | 774 +++++++++++++++--- ...traOngoingNotificationDismissedReceiver.kt | 15 + .../AndroidOngoingNotificationPayload.kt | 93 +++ ...AndroidOngoingNotificationPayloadParser.kt | 13 + packages/voltra/src/VoltraModule.ts | 25 - packages/voltra/src/android/client.ts | 74 +- .../components/VoltraWidgetPreview.tsx | 6 +- packages/voltra/src/android/index.ts | 51 +- .../__tests__/renderer.node.test.tsx | 242 ++++++ .../voltra/src/android/live-update/api.ts | 244 ------ .../src/android/live-update/renderer.ts | 51 -- .../voltra/src/android/live-update/types.ts | 115 --- packages/voltra/src/android/server.ts | 26 +- .../docs/android/api/plugin-configuration.md | 16 + website/docs/android/development/_meta.json | 5 + .../managing-ongoing-notifications.md | 524 ++++++++++++ website/docs/android/introduction.md | 4 + 49 files changed, 4217 insertions(+), 983 deletions(-) create mode 100644 example/app/testing-grounds/android-ongoing-notification.tsx create mode 100644 example/eas.json create mode 100644 example/notifications/registerBackgroundNotifications.ts create mode 100644 example/notifications/registerPushLogging.ts create mode 100644 example/notifications/voltraAndroidOngoingNotificationBackground.ts delete mode 100644 example/screens/live-activities/BasicAndroidLiveUpdate.tsx create mode 100644 example/screens/live-activities/BasicAndroidOngoingNotification.tsx create mode 100644 example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx create mode 100644 packages/android/src/live-update/components.tsx create mode 100644 packages/voltra/android/src/main/java/voltra/VoltraOngoingNotificationDismissedReceiver.kt create mode 100644 packages/voltra/android/src/main/java/voltra/ongoingnotification/AndroidOngoingNotificationPayload.kt create mode 100644 packages/voltra/android/src/main/java/voltra/ongoingnotification/AndroidOngoingNotificationPayloadParser.kt create mode 100644 packages/voltra/src/android/live-update/__tests__/renderer.node.test.tsx delete mode 100644 packages/voltra/src/android/live-update/api.ts delete mode 100644 packages/voltra/src/android/live-update/renderer.ts delete mode 100644 packages/voltra/src/android/live-update/types.ts create mode 100644 website/docs/android/development/managing-ongoing-notifications.md diff --git a/example/app.json b/example/app.json index 86d2a41f..a082b0ae 100644 --- a/example/app.json +++ b/example/app.json @@ -1,7 +1,7 @@ { "expo": { "name": "Voltra Example", - "slug": "voltra-example", + "slug": "voltra", "scheme": "voltra", "version": "1.0.0", "orientation": "portrait", @@ -56,6 +56,7 @@ } ], "android": { + "enableNotifications": true, "widgets": [ { "id": "voltra", @@ -149,6 +150,13 @@ ] } ], + [ + "expo-notifications", + { + "color": "#8232FF", + "defaultChannel": "voltra_live_updates" + } + ], "expo-router", [ "expo-build-properties", @@ -158,6 +166,28 @@ } } ] - ] + ], + "extra": { + "eas": { + "build": { + "experimental": { + "ios": { + "appExtensions": [ + { + "targetName": "VoltraExampleLiveActivity", + "bundleIdentifier": "com.callstackincubator.voltraexample.VoltraExampleLiveActivity", + "entitlements": { + "com.apple.security.application-groups": ["group.callstackincubator.voltraexample"] + } + } + ] + } + } + }, + "projectId": "531aac5b-d8a5-4263-8ac3-7eaf19e75021" + }, + "router": {} + }, + "owner": "aitwar" } } diff --git a/example/app/testing-grounds/android-ongoing-notification.tsx b/example/app/testing-grounds/android-ongoing-notification.tsx new file mode 100644 index 00000000..4ee664c0 --- /dev/null +++ b/example/app/testing-grounds/android-ongoing-notification.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +import AndroidOngoingNotificationTestingScreen from '~/screens/testing-grounds/AndroidOngoingNotificationTestingScreen' + +export default function AndroidOngoingNotificationTestingPage() { + return +} diff --git a/example/eas.json b/example/eas.json new file mode 100644 index 00000000..b8527a15 --- /dev/null +++ b/example/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 18.5.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/example/index.js b/example/index.js index b5cb964d..a6edfce4 100644 --- a/example/index.js +++ b/example/index.js @@ -1,6 +1,14 @@ import { registerRootComponent } from 'expo' import { ExpoRoot } from 'expo-router' +import { registerVoltraBackgroundNotifications } from './notifications/registerBackgroundNotifications' +import { registerPushLogging } from './notifications/registerPushLogging' + +registerVoltraBackgroundNotifications().catch(() => {}) +registerPushLogging().catch((error) => { + console.log('[expo-notifications] Startup registration failed:', error) +}) + export function App() { const ctx = require.context('./app') return diff --git a/example/notifications/registerBackgroundNotifications.ts b/example/notifications/registerBackgroundNotifications.ts new file mode 100644 index 00000000..22c9d10c --- /dev/null +++ b/example/notifications/registerBackgroundNotifications.ts @@ -0,0 +1,34 @@ +import * as Notifications from 'expo-notifications' +import * as TaskManager from 'expo-task-manager' + +import { + handleBackgroundNotificationTask, + VOLTRA_BACKGROUND_NOTIFICATION_TASK, +} from './voltraAndroidOngoingNotificationBackground' + +if (!TaskManager.isTaskDefined(VOLTRA_BACKGROUND_NOTIFICATION_TASK)) { + TaskManager.defineTask( + VOLTRA_BACKGROUND_NOTIFICATION_TASK, + async ({ data, error, executionInfo }) => { + if (error) { + console.log('[voltra-background-task] Task invocation error:', error) + return + } + + console.log('[voltra-background-task] Task invoked:', executionInfo?.eventId ?? 'unknown-event') + + await handleBackgroundNotificationTask({ data }) + } + ) +} + +let backgroundTaskRegistration: Promise | null = null + +export const registerVoltraBackgroundNotifications = async () => { + if (!backgroundTaskRegistration) { + backgroundTaskRegistration = Notifications.registerTaskAsync(VOLTRA_BACKGROUND_NOTIFICATION_TASK) + } + + await backgroundTaskRegistration + console.log('[voltra-background-task] Background notification task registered:', VOLTRA_BACKGROUND_NOTIFICATION_TASK) +} diff --git a/example/notifications/registerPushLogging.ts b/example/notifications/registerPushLogging.ts new file mode 100644 index 00000000..fe92913c --- /dev/null +++ b/example/notifications/registerPushLogging.ts @@ -0,0 +1,61 @@ +import Constants from 'expo-constants' +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' + +import { ensureOngoingNotificationChannel } from './voltraAndroidOngoingNotificationBackground' + +let pushLoggingRegistration: Promise | null = null + +const getProjectId = () => { + return Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId ?? null +} + +export const registerPushLogging = async () => { + if (pushLoggingRegistration) { + return pushLoggingRegistration + } + + pushLoggingRegistration = (async () => { + await ensureOngoingNotificationChannel() + + const existingPermissions = await Notifications.getPermissionsAsync() + let finalStatus = existingPermissions.status + + if (finalStatus !== 'granted') { + const requestedPermissions = await Notifications.requestPermissionsAsync() + finalStatus = requestedPermissions.status + } + + if (finalStatus !== 'granted') { + console.log('[expo-notifications] Push permissions not granted:', finalStatus) + return + } + + try { + const deviceToken = await Notifications.getDevicePushTokenAsync() + console.log('[expo-notifications] Device push token:', deviceToken) + } catch (error) { + console.log('[expo-notifications] Failed to fetch device push token:', error) + } + + const projectId = getProjectId() + if (!projectId) { + console.log('[expo-notifications] Missing EAS projectId; skipping Expo push token fetch.') + return + } + + try { + const expoToken = await Notifications.getExpoPushTokenAsync({ projectId }) + console.log('[expo-notifications] Expo push token:', expoToken.data) + } catch (error) { + console.log('[expo-notifications] Failed to fetch Expo push token:', error) + } + + if (Platform.OS === 'android') { + const channels = await Notifications.getNotificationChannelsAsync() + console.log('[expo-notifications] Android channels:', channels?.map((channel) => channel.id) ?? []) + } + })() + + return pushLoggingRegistration +} diff --git a/example/notifications/voltraAndroidOngoingNotificationBackground.ts b/example/notifications/voltraAndroidOngoingNotificationBackground.ts new file mode 100644 index 00000000..0dd68167 --- /dev/null +++ b/example/notifications/voltraAndroidOngoingNotificationBackground.ts @@ -0,0 +1,243 @@ +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' +import type { AndroidOngoingNotificationPayload } from 'voltra/android' +import type { StartAndroidOngoingNotificationOptions } from 'voltra/android/client' +import { stopAndroidOngoingNotification, upsertAndroidOngoingNotification } from '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 parseOptions = ( + message: VoltraOngoingNotificationMessage +): StartAndroidOngoingNotificationOptions | undefined => { + const rawOptions = isRecord(message.options) + ? message.options + : typeof message.options === 'string' + ? parseJsonString(message.options) + : null + + 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), + } + + const options: StartAndroidOngoingNotificationOptions = { + channelId: DEFAULT_CHANNEL_ID, + } + + 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 StartAndroidOngoingNotificationOptions['fallbackBehavior'] + } + + return options +} + +export const ensureOngoingNotificationChannel = async (channelId = DEFAULT_CHANNEL_ID) => { + if (Platform.OS !== 'android') { + return + } + + await Notifications.setNotificationChannelAsync(channelId, { + name: 'Voltra Ongoing Notifications', + description: 'Background Android ongoing notification tests for Voltra', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 200, 150, 200], + lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC, + showBadge: false, + }) +} + +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 handleBackgroundNotificationTask = async ({ data }: NotificationTaskPayloadData) => { + try { + await processVoltraOngoingNotificationMessage(data) + } catch (error) { + console.log('[voltra-background-task] Handler failed:', error) + throw error + } +} diff --git a/example/package.json b/example/package.json index df13b9db..60cd89b4 100644 --- a/example/package.json +++ b/example/package.json @@ -39,17 +39,18 @@ "expo-status-bar": "~55.0.4", "expo-symbols": "~55.0.5", "expo-system-ui": "~55.0.10", + "expo-task-manager": "^55.0.12", "expo-web-browser": "~55.0.10", "react": "19.2.4", "react-dom": "19.2.4", "react-native": "0.83.2", "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", - "react-native-worklets": "~0.7.0", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", "react-native-web": "^0.21.0", "react-native-webview": "13.16.0", + "react-native-worklets": "~0.7.0", "voltra": "*" }, "devDependencies": { diff --git a/example/screens/android/AndroidScreen.tsx b/example/screens/android/AndroidScreen.tsx index 9a513ab8..1b126a67 100644 --- a/example/screens/android/AndroidScreen.tsx +++ b/example/screens/android/AndroidScreen.tsx @@ -62,6 +62,13 @@ const ANDROID_SECTIONS = [ 'Render text with custom fonts in Android Glance widgets using bitmap rendering. Includes Pacifico (script) and Press Start 2P (pixel) demo.', route: '/android-widgets/custom-fonts', }, + { + id: 'ongoing-notifications', + title: 'Android Ongoing Notifications', + description: + 'Test semantic Android ongoing notifications with local start, update, upsert, stop, promotion capability checks, and real notification data payloads.', + route: '/testing-grounds/android-ongoing-notification', + }, // Add more Android-specific sections here as they are implemented ] @@ -73,8 +80,8 @@ export default function AndroidScreen() { Voltra for Android - Voltra for Android lets you build custom Android Widgets and Live Updates using React Native - no need to - write Kotlin or XML anymore. + Voltra for Android lets you build custom Android Widgets and ongoing notifications using React Native - no + need to write Kotlin or XML anymore. diff --git a/example/screens/live-activities/BasicAndroidLiveUpdate.tsx b/example/screens/live-activities/BasicAndroidLiveUpdate.tsx deleted file mode 100644 index 302aaa34..00000000 --- a/example/screens/live-activities/BasicAndroidLiveUpdate.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' -import { VoltraAndroid } from 'voltra/android' -import { useAndroidLiveUpdate } from 'voltra/android/client' - -import { LiveActivityExampleComponent } from './types' - -const BasicAndroidLiveUpdate: LiveActivityExampleComponent = forwardRef( - ({ autoUpdate = true, autoStart = false, onIsActiveChange }, ref) => { - const [elapsedSeconds, setElapsedSeconds] = useState(0) - - const formatTime = useMemo(() => { - const mins = Math.floor(elapsedSeconds / 60) - const secs = elapsedSeconds % 60 - return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` - }, [elapsedSeconds]) - - const progress = useMemo(() => (elapsedSeconds % 60) / 60, [elapsedSeconds]) - - const variants = useMemo( - () => ({ - collapsed: ( - - - - - - Voltra Live Update - Running: {formatTime} - - - ), - expanded: ( - - - - Hello, Voltra! - - - - - - No animations, but it works! - - - - - - - - Updated at {new Date().toLocaleTimeString()} - - - - ), - }), - [formatTime, progress] - ) - - const { start, update, end, isActive } = useAndroidLiveUpdate(variants, { - updateName: 'basic-android-demo', - autoUpdate, - autoStart, - }) - - useEffect(() => { - onIsActiveChange?.(isActive) - }, [isActive, onIsActiveChange]) - - useEffect(() => { - if (!isActive) { - setElapsedSeconds(0) - return - } - - const interval = setInterval(() => { - setElapsedSeconds((prev) => prev + 1) - }, 1000) - - return () => clearInterval(interval) - }, [isActive]) - - useImperativeHandle(ref, () => ({ - start, - update, - end, - isActive, - })) - - return null - } -) - -BasicAndroidLiveUpdate.displayName = 'BasicAndroidLiveUpdate' - -export default BasicAndroidLiveUpdate diff --git a/example/screens/live-activities/BasicAndroidOngoingNotification.tsx b/example/screens/live-activities/BasicAndroidOngoingNotification.tsx new file mode 100644 index 00000000..5796eba9 --- /dev/null +++ b/example/screens/live-activities/BasicAndroidOngoingNotification.tsx @@ -0,0 +1,71 @@ +import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import { AndroidOngoingNotification } from 'voltra/android' +import { useAndroidOngoingNotification } from 'voltra/android/client' + +import { LiveActivityExampleComponent } from './types' + +const BasicAndroidOngoingNotification: LiveActivityExampleComponent = forwardRef( + ({ autoUpdate = true, autoStart = false, onIsActiveChange }, ref) => { + const [elapsedSeconds, setElapsedSeconds] = useState(0) + + const formatTime = useMemo(() => { + const mins = Math.floor(elapsedSeconds / 60) + const secs = elapsedSeconds % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + }, [elapsedSeconds]) + + const progress = useMemo(() => (elapsedSeconds % 60) / 60, [elapsedSeconds]) + + const variants = useMemo( + () => ( + + ), + [elapsedSeconds, formatTime, progress] + ) + + const { start, update, end, isActive } = useAndroidOngoingNotification(variants, { + notificationId: 'basic-android-demo', + channelId: 'voltra_live_updates', + autoUpdate, + autoStart, + }) + + useEffect(() => { + onIsActiveChange?.(isActive) + }, [isActive, onIsActiveChange]) + + useEffect(() => { + if (!isActive) { + setElapsedSeconds(0) + return + } + + const interval = setInterval(() => { + setElapsedSeconds((prev) => prev + 1) + }, 1000) + + return () => clearInterval(interval) + }, [isActive]) + + useImperativeHandle(ref, () => ({ + start, + update, + end, + isActive, + })) + + return null + } +) + +BasicAndroidOngoingNotification.displayName = 'BasicAndroidOngoingNotification' + +export default BasicAndroidOngoingNotification diff --git a/example/screens/live-activities/types.ts b/example/screens/live-activities/types.ts index b1c03301..b73ce775 100644 --- a/example/screens/live-activities/types.ts +++ b/example/screens/live-activities/types.ts @@ -7,7 +7,7 @@ export type LiveActivityExampleComponentProps = { } export type LiveActivityExampleComponentRef = { - start: () => Promise + start: () => Promise end: () => Promise update: () => Promise } diff --git a/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx b/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx new file mode 100644 index 00000000..48f8ddb1 --- /dev/null +++ b/example/screens/testing-grounds/AndroidOngoingNotificationTestingScreen.tsx @@ -0,0 +1,758 @@ +import { useFocusEffect } from 'expo-router' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { AppState, PermissionsAndroid, Platform, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' +import { + AndroidOngoingNotification, + type AndroidOngoingNotificationPayload, + type StartAndroidOngoingNotificationOptions, +} from 'voltra/android' +import { + getAndroidOngoingNotificationCapabilities, + isAndroidOngoingNotificationActive, + renderAndroidOngoingNotificationPayload, + startAndroidOngoingNotification, + stopAndroidOngoingNotification, + upsertAndroidOngoingNotification, + updateAndroidOngoingNotification, +} from 'voltra/android/client' + +import { Button } from '~/components/Button' +import { Card } from '~/components/Card' + +type OngoingNotificationStyle = 'progress' | 'bigText' + +const DEFAULT_NOTIFICATION_ID = 'testing-ground-android-ongoing-notification' +const DEFAULT_SEGMENTS = '[{"length": 40, "color": "#34D399"}, {"length": 60}]' +const DEFAULT_POINTS = '[{"position": 20, "color": "#F59E0B"}, {"position": 72}]' +const DEFAULT_PRIMARY_ACTION_DEEP_LINK = 'voltra://orders/123' +const DEFAULT_SECONDARY_ACTION_DEEP_LINK = 'voltra://orders/123/track' +const DEFAULT_PRIMARY_ACTION_ICON = 'voltra_icon' + +const formatJson = (value: unknown) => JSON.stringify(value, null, 2) + +const parseInteger = (value: string, fallback: number) => { + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) ? parsed : fallback +} + +const parseJsonArray = (value: string, fallback: T[]): T[] => { + if (!value.trim()) { + return fallback + } + + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? (parsed as T[]) : fallback + } catch { + return fallback + } +} + +const toImageSource = (value: string) => { + const trimmed = value.trim() + if (!trimmed) { + return undefined + } + + return { assetName: trimmed } as const +} + +const toOptionalNonEmptyString = (value: string) => { + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +export default function AndroidOngoingNotificationTestingScreen() { + const [style, setStyle] = useState('progress') + const [notificationId, setNotificationId] = useState(DEFAULT_NOTIFICATION_ID) + const [channelId, setChannelId] = useState('voltra_live_updates') + const [smallIcon, setSmallIcon] = useState('') + const [requestPromotedOngoing, setRequestPromotedOngoing] = useState(true) + const [title, setTitle] = useState('Driver is approaching') + const [text, setText] = useState('2 stops away') + const [bigText, setBigText] = useState('Your courier is moving through the final neighborhood.') + const [subText, setSubText] = useState('ETA 4 min') + const [shortCriticalText, setShortCriticalText] = useState('Soon') + const [progressValue, setProgressValue] = useState('32') + const [progressMax, setProgressMax] = useState('100') + const [indeterminate, setIndeterminate] = useState(false) + const [chronometer, setChronometer] = useState(false) + const [largeIcon, setLargeIcon] = useState('') + const [progressTrackerIcon, setProgressTrackerIcon] = useState('') + const [progressStartIcon, setProgressStartIcon] = useState('') + const [progressEndIcon, setProgressEndIcon] = useState('') + const [segmentsJson, setSegmentsJson] = useState(DEFAULT_SEGMENTS) + const [pointsJson, setPointsJson] = useState(DEFAULT_POINTS) + const [primaryActionTitle, setPrimaryActionTitle] = useState('Open order') + const [primaryActionDeepLinkUrl, setPrimaryActionDeepLinkUrl] = useState(DEFAULT_PRIMARY_ACTION_DEEP_LINK) + const [primaryActionIcon, setPrimaryActionIcon] = useState(DEFAULT_PRIMARY_ACTION_ICON) + const [secondaryActionTitle, setSecondaryActionTitle] = useState('Track driver') + const [secondaryActionDeepLinkUrl, setSecondaryActionDeepLinkUrl] = useState(DEFAULT_SECONDARY_ACTION_DEEP_LINK) + const [renderedPayload, setRenderedPayload] = useState('') + const [statusMessage, setStatusMessage] = useState(null) + const [permissionStatus, setPermissionStatus] = useState(null) + const [activeState, setActiveState] = useState(() => isAndroidOngoingNotificationActive(DEFAULT_NOTIFICATION_ID)) + const [capabilities, setCapabilities] = useState(() => getAndroidOngoingNotificationCapabilities()) + + const refreshCapabilities = useCallback(() => { + setCapabilities(getAndroidOngoingNotificationCapabilities()) + }, []) + + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') { + refreshCapabilities() + } + }) + + return () => subscription.remove() + }, [refreshCapabilities]) + + useFocusEffect(refreshCapabilities) + + const content = useMemo(() => { + if (style === 'progress') { + return ( + + {toOptionalNonEmptyString(primaryActionTitle) && toOptionalNonEmptyString(primaryActionDeepLinkUrl) ? ( + + ) : null} + {toOptionalNonEmptyString(secondaryActionTitle) && toOptionalNonEmptyString(secondaryActionDeepLinkUrl) ? ( + + ) : null} + + ) + } + + return ( + + {toOptionalNonEmptyString(primaryActionTitle) && toOptionalNonEmptyString(primaryActionDeepLinkUrl) ? ( + + ) : null} + {toOptionalNonEmptyString(secondaryActionTitle) && toOptionalNonEmptyString(secondaryActionDeepLinkUrl) ? ( + + ) : null} + + ) + }, [ + bigText, + chronometer, + indeterminate, + largeIcon, + pointsJson, + primaryActionDeepLinkUrl, + primaryActionIcon, + primaryActionTitle, + progressEndIcon, + progressMax, + progressStartIcon, + progressTrackerIcon, + progressValue, + secondaryActionDeepLinkUrl, + secondaryActionTitle, + segmentsJson, + shortCriticalText, + style, + subText, + text, + title, + ]) + + const syncActiveState = (id: string) => { + setActiveState(isAndroidOngoingNotificationActive(id)) + } + + const getOngoingNotificationOptions = () => ({ + notificationId, + channelId, + smallIcon: smallIcon || undefined, + requestPromotedOngoing, + }) + + const buildVoltraPushEnvelope = (operation: 'upsert' | 'stop' = 'upsert') => { + const data: { + voltraOngoingNotification: { + notificationId: string + operation: 'upsert' | 'stop' + options: StartAndroidOngoingNotificationOptions + payload?: AndroidOngoingNotificationPayload + } + } = { + voltraOngoingNotification: { + notificationId, + operation, + options: { + channelId, + smallIcon: smallIcon || undefined, + requestPromotedOngoing, + }, + }, + } + + if (operation === 'upsert') { + const payloadString = renderedPayload || renderAndroidOngoingNotificationPayload(content) + data.voltraOngoingNotification.payload = JSON.parse(payloadString) as AndroidOngoingNotificationPayload + } + + return data + } + + const sampleExpoPushRequest = useMemo(() => { + try { + return formatJson({ + to: 'ExponentPushToken[project-token]', + priority: 'high', + data: { + voltraOngoingNotification: JSON.stringify(buildVoltraPushEnvelope('upsert').voltraOngoingNotification), + }, + }) + } catch { + return 'Render a valid payload first to generate a data-only Expo push example.' + } + }, [buildVoltraPushEnvelope]) + + const handleRenderPayload = () => { + const payload = renderAndroidOngoingNotificationPayload(content) + setRenderedPayload(payload) + setStatusMessage('Rendered semantic payload from JSX content.') + } + + const handleStart = async () => { + try { + const result = await startAndroidOngoingNotification(content, getOngoingNotificationOptions()) + syncActiveState(result.notificationId) + setStatusMessage( + result.ok + ? `Started Android ongoing notification "${result.notificationId}".` + : `Did not start Android ongoing notification "${result.notificationId}": ${result.reason}.` + ) + setRenderedPayload(renderAndroidOngoingNotificationPayload(content)) + } catch (error) { + setStatusMessage(error instanceof Error ? error.message : 'Failed to start Android ongoing notification.') + } + } + + const handleUpdate = async () => { + try { + const result = await updateAndroidOngoingNotification(notificationId, content, getOngoingNotificationOptions()) + syncActiveState(notificationId) + setStatusMessage( + result.ok + ? `Updated Android ongoing notification "${notificationId}".` + : `Did not update Android ongoing notification "${notificationId}": ${result.reason}.` + ) + setRenderedPayload(renderAndroidOngoingNotificationPayload(content)) + } catch (error) { + setStatusMessage(error instanceof Error ? error.message : 'Failed to update Android ongoing notification.') + } + } + + const handleUpsertPayload = async () => { + try { + const payloadString = renderedPayload || renderAndroidOngoingNotificationPayload(content) + const payload = JSON.parse(payloadString) as AndroidOngoingNotificationPayload + const result = await upsertAndroidOngoingNotification(payload, getOngoingNotificationOptions()) + syncActiveState(result.notificationId) + setRenderedPayload(formatJson(payload)) + setStatusMessage( + result.ok + ? `Upserted semantic payload for "${result.notificationId}" via ${result.action}.` + : `Did not upsert ongoing notification "${result.notificationId}": ${result.reason}.` + ) + } catch (error) { + setStatusMessage( + error instanceof Error ? error.message : 'Failed to upsert Android ongoing notification payload.' + ) + } + } + + const handleStop = async () => { + try { + const result = await stopAndroidOngoingNotification(notificationId) + syncActiveState(notificationId) + setStatusMessage( + result.ok + ? `Stopped Android ongoing notification "${notificationId}".` + : `Did not stop Android ongoing notification "${notificationId}": ${result.reason}.` + ) + } catch (error) { + setStatusMessage(error instanceof Error ? error.message : 'Failed to stop Android ongoing notification.') + } + } + + const handleRefreshActiveState = () => { + syncActiveState(notificationId) + setStatusMessage(`Checked active state for "${notificationId}".`) + } + + const handleRequestNotificationPermission = async () => { + if (Platform.OS !== 'android') { + return + } + + if (Platform.Version < 33) { + setPermissionStatus('Notification runtime permission is not required below Android 13.') + return + } + + try { + const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS) + setPermissionStatus(`Notification permission result: ${result}`) + refreshCapabilities() + } catch (error) { + setPermissionStatus(error instanceof Error ? error.message : 'Failed to request notification permission.') + } + } + + if (Platform.OS !== 'android') { + return ( + + + Android Ongoing Notifications + This testing ground is only available on Android. + + + ) + } + + return ( + + + Android Ongoing Notifications + + 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 + + Segments, points, and tracker/start/end icons are applied on API 36+ only and ignored silently on older + Android versions. + + Large icon and subtext remain available outside that richer path. + + + + Action Buttons + Action children launch their own deep links. + + Action icons are still wired into the payload, but standard Android notification UI usually does not render + them. + + Use the icon fields here to verify payload support, not visible action-button artwork. + Unknown children are ignored, so this screen only renders explicit action entries. + + + + Runtime Capabilities + {`API ${capabilities.apiLevel} • notifications ${ + capabilities.notificationsEnabled ? 'enabled' : 'disabled' + }`} + {`Promoted support: ${capabilities.supportsPromotedNotifications ? 'yes' : 'no'}`} + {`Can post promoted notifications: ${ + capabilities.canPostPromotedNotifications ? 'yes' : 'no' + }`} + {`Can request promoted ongoing: ${ + capabilities.canRequestPromotedOngoing ? 'yes' : 'no' + }`} + +