diff --git a/.changeset/green-planes-sing.md b/.changeset/green-planes-sing.md new file mode 100644 index 00000000..63bd0137 --- /dev/null +++ b/.changeset/green-planes-sing.md @@ -0,0 +1,8 @@ +--- +'@use-voltra/android': minor +'@use-voltra/android-server': minor +'@use-voltra/expo-plugin': minor +'voltra': minor +--- + +Add Android ongoing notification support, including richer notification content, remote update flows, and server-side payload rendering APIs. This release also expands the Expo integration and documentation so apps can configure, send, and manage Android ongoing notifications more easily. 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' + }`} + +