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'
+ }`}
+
+
+
+ {permissionStatus ? {permissionStatus} : null}
+
+
+
+ Ongoing Notification Target
+
+ Notification ID
+
+
+
+ Channel ID
+
+
+
+ Small Icon
+
+
+
+ Request Promoted Ongoing
+
+
+ Active State
+
+ {activeState ? 'Active' : 'Idle'}
+
+
+
+
+
+
+ Semantic Content
+
+ Style
+
+
+
+
+
+ Title
+
+
+
+ Text
+
+
+
+ Sub Text
+
+
+
+ Short Critical
+
+
+
+ Large Icon
+
+
+
+ Chronometer
+ setChronometer((current) => !current)}
+ style={styles.smButton}
+ />
+
+
+ {style === 'progress' ? (
+ <>
+
+ Value
+
+
+
+ Max
+
+
+
+ Indeterminate
+ setIndeterminate((current) => !current)}
+ style={styles.smButton}
+ />
+
+
+ Tracker Icon
+
+
+
+ Start Icon
+
+
+
+ End Icon
+
+
+
+ Segments JSON
+
+
+
+ Points JSON
+
+
+
+ Action Buttons
+
+ Primary Title
+
+
+
+ Primary Deep Link
+
+
+
+ Primary Icon
+
+
+
+ Secondary Title
+
+
+
+ Secondary Deep Link
+
+
+
+ >
+ ) : (
+ <>
+
+ Big Text
+
+
+
+ Action Buttons
+
+ Primary Title
+
+
+
+ Primary Deep Link
+
+
+
+ Primary Icon
+
+
+
+ Secondary Title
+
+
+
+ Secondary Deep Link
+
+
+
+ >
+ )}
+
+
+
+ Actions
+
+
+
+
+
+
+
+ {statusMessage ? {statusMessage} : null}
+
+
+
+ Rendered Payload
+
+ {renderedPayload || 'Render a payload to inspect the semantic snapshot.'}
+
+
+
+
+ Real Notification Test
+
+ Send a real high-priority notification whose `data.voltraOngoingNotification` contains this envelope. The
+ example's registered background task will parse it and upsert the ongoing notification.
+
+ Use `operation: "stop"` with the same `notificationId` to stop it remotely.
+ {sampleExpoPushRequest}
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1 },
+ scrollView: { flex: 1 },
+ content: { padding: 20 },
+ heading: { fontSize: 24, fontWeight: '700', color: '#FFFFFF', marginBottom: 8 },
+ subheading: { fontSize: 14, color: '#CBD5F5', marginBottom: 24, lineHeight: 20 },
+ row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12 },
+ column: { marginBottom: 16, gap: 10 },
+ label: { color: '#FFFFFF', fontSize: 16, flexShrink: 0 },
+ input: {
+ flex: 1,
+ minWidth: 140,
+ backgroundColor: 'rgba(255,255,255,0.08)',
+ color: '#FFFFFF',
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: 'rgba(148, 163, 184, 0.2)',
+ textAlign: 'right',
+ },
+ multilineInput: {
+ minHeight: 96,
+ textAlign: 'left',
+ textAlignVertical: 'top',
+ },
+ toggleGroup: { flexDirection: 'row', gap: 8 },
+ buttonRow: { marginTop: 16 },
+ smButton: { paddingVertical: 8, paddingHorizontal: 16 },
+ badge: {
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 999,
+ overflow: 'hidden',
+ fontSize: 12,
+ fontWeight: '600',
+ },
+ badgeActive: { backgroundColor: 'rgba(34, 197, 94, 0.18)', color: '#86EFAC' },
+ badgeIdle: { backgroundColor: 'rgba(148, 163, 184, 0.15)', color: '#CBD5F5' },
+ buttonGrid: { gap: 12 },
+ status: { marginTop: 16, color: '#A78BFA', fontSize: 13, lineHeight: 18 },
+ codeBlock: {
+ marginTop: 12,
+ color: '#E2E8F0',
+ backgroundColor: 'rgba(15, 23, 42, 0.9)',
+ borderRadius: 12,
+ padding: 14,
+ fontFamily: 'Menlo',
+ fontSize: 12,
+ lineHeight: 18,
+ },
+})
diff --git a/package-lock.json b/package-lock.json
index ac68ac49..01b1172b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -51,6 +51,7 @@
"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",
@@ -11764,6 +11765,19 @@
"react-native": "*"
}
},
+ "node_modules/expo-task-manager": {
+ "version": "55.0.12",
+ "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-55.0.12.tgz",
+ "integrity": "sha512-lKt0uLiIIZyWTn8tD7KDMFr0QZVAbBo8OLn0IBGN2aapJBB2A//VSd7EuD+NUkU9jdlxfG6Co63ZCt0X1/NeUA==",
+ "license": "MIT",
+ "dependencies": {
+ "unimodules-app-loader": "~55.0.4"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/exponential-backoff": {
"version": "3.1.2",
"license": "Apache-2.0"
@@ -19082,6 +19096,12 @@
"node": ">=4"
}
},
+ "node_modules/unimodules-app-loader": {
+ "version": "55.0.4",
+ "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-55.0.4.tgz",
+ "integrity": "sha512-l3vMWR/lYLTj3JE4rhIX5vDVMDY9nGS550XaB90HENqUQnBEMdhhOpI8tOA37QJOUZE6pCQGeQX6mIdEu3uqzg==",
+ "license": "MIT"
+ },
"node_modules/unique-string": {
"version": "2.0.0",
"license": "MIT",
diff --git a/packages/android-server/README.md b/packages/android-server/README.md
index 330f592d..af00eb6e 100644
--- a/packages/android-server/README.md
+++ b/packages/android-server/README.md
@@ -4,7 +4,23 @@
[![mit licence][license-badge]][license] [![npm downloads][npm-downloads-badge]][npm-downloads] [![PRs Welcome][prs-welcome-badge]][prs-welcome]
-`@use-voltra/android-server` contains the Android server rendering package for Voltra, including Android widget rendering, Android live update rendering, and Android widget update handlers.
+`@use-voltra/android-server` contains the Android server rendering package for Voltra, including:
+
+- Android widget rendering
+- Android ongoing-notification payload rendering
+- Android widget update handlers
+
+For Android ongoing notifications, render JSX into a semantic payload with:
+
+```tsx
+import { AndroidOngoingNotification, renderAndroidOngoingNotificationPayloadToJson } from '@use-voltra/android-server'
+
+const payload = renderAndroidOngoingNotificationPayloadToJson(
+
+)
+```
+
+Use this package only in backend or server-side environments. Do not import server renderers from React Native app runtime code.
> [!WARNING]
> This package is not intended to be installed directly in your app. Most apps should install `voltra` instead.
diff --git a/packages/android-server/src/index.ts b/packages/android-server/src/index.ts
index 588380c9..b3c2381b 100644
--- a/packages/android-server/src/index.ts
+++ b/packages/android-server/src/index.ts
@@ -1,6 +1,11 @@
///
import { createVoltraRenderer } from '@use-voltra/core'
+export {
+ AndroidOngoingNotification,
+ renderAndroidOngoingNotificationPayload,
+ renderAndroidOngoingNotificationPayloadToJson,
+} from '@use-voltra/android/server'
import type {
WidgetRenderRequest,
WidgetUpdateExpressHandler,
@@ -14,6 +19,18 @@ import {
} from '@use-voltra/server'
import { createElement, Fragment as ReactFragment, type ReactNode } from 'react'
+export type {
+ AndroidOngoingNotificationActionPayload,
+ AndroidOngoingNotificationActionProps,
+ AndroidOngoingNotificationBigTextPayload,
+ AndroidOngoingNotificationBigTextProps,
+ AndroidOngoingNotificationContent,
+ AndroidOngoingNotificationPayload,
+ AndroidOngoingNotificationProgressPayload,
+ AndroidOngoingNotificationProgressPoint,
+ AndroidOngoingNotificationProgressProps,
+ AndroidOngoingNotificationProgressSegment,
+} from '@use-voltra/android/server'
export type {
WidgetRenderRequest,
WidgetUpdateExpressHandler,
@@ -35,25 +52,6 @@ export type AndroidWidgetSizeVariant = {
export type AndroidWidgetVariants = AndroidWidgetSizeVariant[]
-export type AndroidLiveUpdateVariants = {
- collapsed?: ReactNode
- expanded?: ReactNode
- smallIcon?: string
- channelId?: string
-}
-
-export type AndroidLiveUpdateVariantsJson = {
- v: number
- s?: Record[]
- e?: unknown[]
- collapsed?: unknown
- expanded?: unknown
- smallIcon?: string
- channelId?: string
-}
-
-export type AndroidLiveUpdateJson = AndroidLiveUpdateVariantsJson
-
type AndroidWidgetRenderOptions = Record
const ANDROID_COMPONENT_NAME_TO_ID: Record = {
@@ -97,34 +95,6 @@ const androidComponentRegistry = {
getComponentId: (name: string) => getAndroidComponentId(name),
}
-export const renderAndroidLiveUpdateToJson = (variants: AndroidLiveUpdateVariants): AndroidLiveUpdateJson => {
- const renderer = createVoltraRenderer(androidComponentRegistry)
-
- if (variants.collapsed) {
- renderer.addRootNode('collapsed', variants.collapsed)
- }
-
- if (variants.expanded) {
- renderer.addRootNode('expanded', variants.expanded)
- }
-
- const result = renderer.render() as AndroidLiveUpdateJson
-
- if (variants.smallIcon) {
- result.smallIcon = variants.smallIcon
- }
-
- if (variants.channelId) {
- result.channelId = variants.channelId
- }
-
- return result
-}
-
-export const renderAndroidLiveUpdateToString = (variants: AndroidLiveUpdateVariants): string => {
- return JSON.stringify(renderAndroidLiveUpdateToJson(variants))
-}
-
export const renderAndroidWidgetToJson = (
variants: AndroidWidgetVariants,
_options?: AndroidWidgetRenderOptions
diff --git a/packages/android-server/tsconfig.base.json b/packages/android-server/tsconfig.base.json
index e8edd987..e1cabcc3 100644
--- a/packages/android-server/tsconfig.base.json
+++ b/packages/android-server/tsconfig.base.json
@@ -5,6 +5,7 @@
"rootDir": "./src",
"moduleResolution": "node",
"paths": {
+ "@use-voltra/android/server": ["../android/build/types/server.d.ts"],
"@use-voltra/android/internal": ["../android/build/types/internal.d.ts"]
},
"jsx": "react-jsx",
diff --git a/packages/android/src/VoltraModule.ts b/packages/android/src/VoltraModule.ts
index cf605b8e..4d749bc0 100644
--- a/packages/android/src/VoltraModule.ts
+++ b/packages/android/src/VoltraModule.ts
@@ -1,13 +1,63 @@
import { requireNativeModule } from 'expo'
+import type {
+ StartAndroidOngoingNotificationOptions,
+ UpdateAndroidOngoingNotificationOptions,
+} from './live-update/types.js'
import type { EventSubscription, PreloadImageOptions, PreloadImagesResult, WidgetServerCredentials } from './types.js'
export interface VoltraAndroidModuleSpec {
- startAndroidLiveUpdate(payload: string, options: { updateName?: string; channelId?: string }): Promise
- updateAndroidLiveUpdate(notificationId: string, payload: string): Promise
- stopAndroidLiveUpdate(notificationId: string): Promise
- isAndroidLiveUpdateActive(updateName: string): boolean
- endAllAndroidLiveUpdates(): Promise
+ startAndroidOngoingNotification(
+ payload: string,
+ options: StartAndroidOngoingNotificationOptions
+ ): Promise<{
+ ok: boolean
+ notificationId: string
+ action?: 'started'
+ reason?: 'already_exists'
+ }>
+ upsertAndroidOngoingNotification(
+ payload: string,
+ options: StartAndroidOngoingNotificationOptions
+ ): Promise<{
+ ok: boolean
+ notificationId: string
+ action?: 'started' | 'updated'
+ reason?: 'already_exists' | 'dismissed'
+ }>
+ updateAndroidOngoingNotification(
+ notificationId: string,
+ payload: string,
+ options?: UpdateAndroidOngoingNotificationOptions
+ ): Promise<{
+ ok: boolean
+ notificationId: string
+ action?: 'updated'
+ reason?: 'dismissed' | 'not_found'
+ }>
+ stopAndroidOngoingNotification(notificationId: string): Promise<{
+ ok: boolean
+ notificationId: string
+ action?: 'stopped'
+ reason?: 'not_found'
+ }>
+ isAndroidOngoingNotificationActive(notificationId: string): boolean
+ getAndroidOngoingNotificationStatus(notificationId: string): {
+ isActive: boolean
+ isDismissed: boolean
+ isPromoted?: boolean
+ hasPromotableCharacteristics?: boolean
+ }
+ endAllAndroidOngoingNotifications(): Promise
+ canPostPromotedAndroidNotifications(): boolean
+ getAndroidOngoingNotificationCapabilities(): {
+ apiLevel: number
+ notificationsEnabled: boolean
+ supportsPromotedNotifications: boolean
+ canPostPromotedNotifications: boolean
+ canRequestPromotedOngoing: boolean
+ }
+ openAndroidNotificationSettings(): Promise
updateAndroidWidget(widgetId: string, jsonString: string, options?: { deepLinkUrl?: string }): Promise
reloadAndroidWidgets(widgetIds?: string[] | null): Promise
clearAndroidWidget(widgetId: string): Promise
diff --git a/packages/android/src/client.ts b/packages/android/src/client.ts
index a63f5374..8e7e1f40 100644
--- a/packages/android/src/client.ts
+++ b/packages/android/src/client.ts
@@ -1,20 +1,46 @@
-// Android Live Update API and types
+export { AndroidOngoingNotification } from './live-update/components.js'
+
+// Android ongoing notification API and types
export {
- endAllAndroidLiveUpdates,
- isAndroidLiveUpdateActive,
- startAndroidLiveUpdate,
- stopAndroidLiveUpdate,
- updateAndroidLiveUpdate,
- useAndroidLiveUpdate,
+ canPostPromotedAndroidNotifications,
+ endAllAndroidOngoingNotifications,
+ getAndroidOngoingNotificationCapabilities,
+ getAndroidOngoingNotificationStatus,
+ hasAndroidNotificationPermission,
+ isAndroidOngoingNotificationActive,
+ openAndroidNotificationSettings,
+ renderAndroidOngoingNotificationPayload,
+ requestAndroidNotificationPermission,
+ startAndroidOngoingNotification,
+ stopAndroidOngoingNotification,
+ upsertAndroidOngoingNotification,
+ updateAndroidOngoingNotification,
+ useAndroidOngoingNotification,
} from './live-update/api.js'
export type {
- AndroidLiveUpdateJson,
- AndroidLiveUpdateVariants,
- AndroidLiveUpdateVariantsJson,
- StartAndroidLiveUpdateOptions,
- UpdateAndroidLiveUpdateOptions,
- UseAndroidLiveUpdateOptions,
- UseAndroidLiveUpdateResult,
+ AndroidOngoingNotificationActionPayload,
+ AndroidOngoingNotificationActionProps,
+ AndroidOngoingNotificationBigTextPayload,
+ AndroidOngoingNotificationBigTextProps,
+ AndroidOngoingNotificationCapabilities,
+ AndroidOngoingNotificationCommonDisplayProps,
+ AndroidOngoingNotificationContent,
+ AndroidOngoingNotificationFallbackBehavior,
+ AndroidOngoingNotificationInput,
+ AndroidOngoingNotificationPayload,
+ AndroidOngoingNotificationProgressPayload,
+ AndroidOngoingNotificationProgressPoint,
+ AndroidOngoingNotificationProgressProps,
+ AndroidOngoingNotificationProgressSegment,
+ AndroidOngoingNotificationStartResult,
+ AndroidOngoingNotificationStatus,
+ AndroidOngoingNotificationStopResult,
+ AndroidOngoingNotificationUpdateResult,
+ AndroidOngoingNotificationUpsertResult,
+ StartAndroidOngoingNotificationOptions,
+ UpdateAndroidOngoingNotificationOptions,
+ UseAndroidOngoingNotificationOptions,
+ UseAndroidOngoingNotificationResult,
} from './live-update/types.js'
// Android Widget API and types
diff --git a/packages/android/src/events.ts b/packages/android/src/events.ts
index eb84787f..dfd8dc1e 100644
--- a/packages/android/src/events.ts
+++ b/packages/android/src/events.ts
@@ -1,5 +1,3 @@
-import { Platform } from 'react-native'
-
import type { EventSubscription } from './types.js'
import VoltraModule from './VoltraModule.js'
@@ -8,36 +6,13 @@ export type BasicVoltraEvent = {
timestamp: number
}
-export type VoltraActivityState = 'active' | 'dismissed' | 'pending' | 'stale' | 'ended' | string
-export type VoltraActivityTokenReceivedEvent = BasicVoltraEvent & {
- type: 'activityTokenReceived'
- activityName: string
- pushToken: string
-}
-export type VoltraActivityPushToStartTokenReceivedEvent = BasicVoltraEvent & {
- type: 'activityPushToStartTokenReceived'
- pushToStartToken: string
-}
-export type VoltraActivityUpdateEvent = BasicVoltraEvent & {
- type: 'stateChange'
- activityName: string
- activityState: VoltraActivityState
-}
-
export type VoltraInteractionEvent = BasicVoltraEvent & {
type: 'interaction'
identifier: string
payload: string
}
-const noopSubscription: EventSubscription = {
- remove: () => {},
-}
-
export type VoltraEventMap = {
- activityTokenReceived: VoltraActivityTokenReceivedEvent
- activityPushToStartTokenReceived: VoltraActivityPushToStartTokenReceivedEvent
- stateChange: VoltraActivityUpdateEvent
interaction: VoltraInteractionEvent
}
@@ -45,13 +20,7 @@ export type VoltraEventMap = {
* Add a listener for Voltra events.
*
* Supported events:
- * - `interaction`: User interactions with widgets (buttons, switches, checkboxes) (iOS only)
- * - `stateChange`: Live Activity state changes (iOS only)
- * - `activityTokenReceived`: Push token for Live Activity (iOS only)
- * - `activityPushToStartTokenReceived`: Push-to-start token (iOS only)
- *
- * Note: On Android, interactions open the app directly (optionally via deep links)
- * instead of emitting background events.
+ * - `interaction`: User interactions with widgets rendered inside a Voltra view.
*
* @param event The event type to listen for
* @param listener Callback function to handle the event
@@ -61,10 +30,5 @@ export function addVoltraListener(
event: K,
listener: (event: VoltraEventMap[K]) => void
): EventSubscription {
- if (Platform.OS !== 'ios') {
- console.warn(`[Voltra] Event '${event}' is only supported on iOS. Returning no-op subscription.`)
- return noopSubscription
- }
-
return VoltraModule.addListener(event, listener)
}
diff --git a/packages/android/src/index.ts b/packages/android/src/index.ts
index 70dcfee0..53bfea06 100644
--- a/packages/android/src/index.ts
+++ b/packages/android/src/index.ts
@@ -1,6 +1,7 @@
// Android component namespace
export * as VoltraAndroid from './jsx/primitives.js'
export { AndroidDynamicColors } from './dynamic-colors.js'
+export { AndroidOngoingNotification } from './live-update/components.js'
// Android types
export type { VoltraAndroidBaseProps } from './jsx/baseProps.js'
@@ -12,6 +13,32 @@ export type {
} from './styles/types.js'
export type { AndroidColorValue, AndroidDynamicColorRole, AndroidDynamicColorToken } from './dynamic-colors.js'
+export type {
+ AndroidOngoingNotificationActionPayload,
+ AndroidOngoingNotificationActionProps,
+ AndroidOngoingNotificationBigTextPayload,
+ AndroidOngoingNotificationBigTextProps,
+ AndroidOngoingNotificationCapabilities,
+ AndroidOngoingNotificationCommonDisplayProps,
+ AndroidOngoingNotificationContent,
+ AndroidOngoingNotificationFallbackBehavior,
+ AndroidOngoingNotificationInput,
+ AndroidOngoingNotificationPayload,
+ AndroidOngoingNotificationProgressPayload,
+ AndroidOngoingNotificationProgressPoint,
+ AndroidOngoingNotificationProgressProps,
+ AndroidOngoingNotificationProgressSegment,
+ AndroidOngoingNotificationStartResult,
+ AndroidOngoingNotificationStatus,
+ AndroidOngoingNotificationStopResult,
+ AndroidOngoingNotificationUpdateResult,
+ AndroidOngoingNotificationUpsertResult,
+ StartAndroidOngoingNotificationOptions,
+ UpdateAndroidOngoingNotificationOptions,
+ UseAndroidOngoingNotificationOptions,
+ UseAndroidOngoingNotificationResult,
+} from './live-update/types.js'
+
// Component prop types
export type { BoxProps } from './jsx/Box.js'
export type { ButtonProps } from './jsx/Button.js'
diff --git a/packages/android/src/live-update/api.ts b/packages/android/src/live-update/api.ts
index 3a80065a..14ffa53a 100644
--- a/packages/android/src/live-update/api.ts
+++ b/packages/android/src/live-update/api.ts
@@ -1,39 +1,131 @@
import { useCallback, useEffect, useRef, useState } from 'react'
+import { PermissionsAndroid, Platform } from 'react-native'
import { useUpdateOnHMR } from '../utils/index.js'
import VoltraModule from '../VoltraModule.js'
-import { renderAndroidLiveUpdateToString } from './renderer.js'
+import { renderAndroidOngoingNotificationContent } from './renderer.js'
import type {
- AndroidLiveUpdateVariants,
- StartAndroidLiveUpdateOptions,
- UpdateAndroidLiveUpdateOptions,
- UseAndroidLiveUpdateOptions,
- UseAndroidLiveUpdateResult,
+ AndroidOngoingNotificationCapabilities,
+ AndroidOngoingNotificationContent,
+ AndroidOngoingNotificationFallbackBehavior,
+ AndroidOngoingNotificationInput,
+ AndroidOngoingNotificationPayload,
+ AndroidOngoingNotificationStartResult,
+ AndroidOngoingNotificationStatus,
+ AndroidOngoingNotificationStopResult,
+ AndroidOngoingNotificationUpdateResult,
+ AndroidOngoingNotificationUpsertResult,
+ StartAndroidOngoingNotificationOptions,
+ UpdateAndroidOngoingNotificationOptions,
+ UseAndroidOngoingNotificationOptions,
+ UseAndroidOngoingNotificationResult,
} from './types.js'
+const NOTIFICATION_PERMISSION = PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
+
+const isAndroidNotificationPermissionRequired = (): boolean => {
+ return Platform.OS === 'android' && Number(Platform.Version) >= 33
+}
+
+const isAndroidOngoingNotificationPayload = (value: unknown): value is AndroidOngoingNotificationPayload => {
+ return typeof value === 'object' && value !== null && 'v' in value && 'kind' in value
+}
+
+const serializeAndroidOngoingNotificationInput = (input: AndroidOngoingNotificationInput): string => {
+ if (typeof input === 'string') {
+ return input
+ }
+
+ if (isAndroidOngoingNotificationPayload(input)) {
+ return JSON.stringify(input)
+ }
+
+ return renderAndroidOngoingNotificationContent(input)
+}
+
+const getFilteredAndroidOngoingNotificationUpdateOptions = (
+ options?: UpdateAndroidOngoingNotificationOptions | StartAndroidOngoingNotificationOptions
+): UpdateAndroidOngoingNotificationOptions | undefined => {
+ if (!options) {
+ return undefined
+ }
+
+ const filteredOptions: UpdateAndroidOngoingNotificationOptions = {}
+
+ if (options.channelId !== undefined) {
+ filteredOptions.channelId = options.channelId
+ }
+
+ if (options.smallIcon !== undefined) {
+ filteredOptions.smallIcon = options.smallIcon
+ }
+
+ if (options.deepLinkUrl !== undefined) {
+ filteredOptions.deepLinkUrl = options.deepLinkUrl
+ }
+
+ if (options.requestPromotedOngoing !== undefined) {
+ filteredOptions.requestPromotedOngoing = options.requestPromotedOngoing
+ }
+
+ if (options.fallbackBehavior !== undefined) {
+ filteredOptions.fallbackBehavior = options.fallbackBehavior as AndroidOngoingNotificationFallbackBehavior
+ }
+
+ return Object.keys(filteredOptions).length > 0 ? filteredOptions : undefined
+}
+
+const getStartAndroidOngoingNotificationOptions = (
+ _input: AndroidOngoingNotificationInput,
+ options: StartAndroidOngoingNotificationOptions
+): StartAndroidOngoingNotificationOptions => {
+ return {
+ notificationId: options.notificationId,
+ channelId: options.channelId,
+ smallIcon: options.smallIcon,
+ deepLinkUrl: options.deepLinkUrl,
+ requestPromotedOngoing: options.requestPromotedOngoing,
+ fallbackBehavior: options.fallbackBehavior,
+ }
+}
+
+const createNotFoundUpdateResult = (notificationId: string): AndroidOngoingNotificationUpdateResult => ({
+ ok: false,
+ notificationId,
+ reason: 'not_found',
+})
+
+const createNotFoundStopResult = (notificationId: string): AndroidOngoingNotificationStopResult => ({
+ ok: false,
+ notificationId,
+ reason: 'not_found',
+})
+
+export { renderAndroidOngoingNotificationPayload } from './renderer.js'
+
/**
* @unstable This API is experimental and may change in future versions.
*
- * React hook for managing Android Live Updates with automatic lifecycle handling.
+ * React hook for managing Android ongoing notifications with automatic lifecycle handling.
*
- * @param variants - The Android Live Update content variants to display
+ * @param variants - The Android ongoing notification content variants to display
* @param options - Configuration options for the hook
* @returns Object with start, update, end methods and isActive state
*
* @example
* ```tsx
- * import { VoltraAndroid, useAndroidLiveUpdate } from 'voltra'
+ * import { AndroidOngoingNotification, useAndroidOngoingNotification } from 'voltra/android/client'
*
- * const MyLiveUpdate = () => {
- * const { start, update, end, isActive } = useAndroidLiveUpdate({
- * collapsed: Delivery arriving,
- * expanded: ...,
- * channelId: 'delivery_updates',
- * }, {
- * updateName: 'my-live-update',
- * autoStart: true,
- * autoUpdate: true
- * })
+ * const MyOngoingNotification = () => {
+ * const { start, update, end, isActive } = useAndroidOngoingNotification(
+ * ,
+ * {
+ * notificationId: 'my-ongoing-notification',
+ * channelId: 'delivery_updates',
+ * autoStart: true,
+ * autoUpdate: true,
+ * }
+ * )
*
* return (
*
@@ -43,21 +135,21 @@ import type {
* }
* ```
*/
-export const useAndroidLiveUpdate = (
- variants: AndroidLiveUpdateVariants,
- options?: UseAndroidLiveUpdateOptions
-): UseAndroidLiveUpdateResult => {
+export const useAndroidOngoingNotification = (
+ content: AndroidOngoingNotificationContent,
+ options: UseAndroidOngoingNotificationOptions
+): UseAndroidOngoingNotificationResult => {
const [targetId, setTargetId] = useState(() => {
- if (options?.updateName) {
- return isAndroidLiveUpdateActive(options.updateName) ? options.updateName : null
+ if (options.notificationId) {
+ return isAndroidOngoingNotificationActive(options.notificationId) ? options.notificationId : null
}
return null
})
const isActive = targetId !== null
const optionsRef = useRef(options)
- const variantsRef = useRef(variants)
- const lastUpdateOptionsRef = useRef(undefined)
+ const contentRef = useRef(content)
+ const lastUpdateOptionsRef = useRef(undefined)
// Update refs when values change
useEffect(() => {
@@ -65,53 +157,70 @@ export const useAndroidLiveUpdate = (
}, [options])
useEffect(() => {
- variantsRef.current = variants
- }, [variants])
+ contentRef.current = content
+ }, [content])
useUpdateOnHMR()
- const start = useCallback(async (options?: StartAndroidLiveUpdateOptions) => {
- const id = await startAndroidLiveUpdate(variantsRef.current, {
- ...optionsRef.current,
- ...options,
- })
- setTargetId(id)
+ const start = useCallback(async (options?: Partial) => {
+ const startOptions = { ...optionsRef.current, ...options }
+ if (!startOptions.channelId) {
+ throw new Error('[Voltra] [Android] Ongoing notifications require an explicit channelId.')
+ }
+
+ const result = await startAndroidOngoingNotification(
+ contentRef.current,
+ startOptions as StartAndroidOngoingNotificationOptions
+ )
+ if (result.ok) {
+ setTargetId(result.notificationId)
+ } else if (result.reason === 'already_exists' && isAndroidOngoingNotificationActive(result.notificationId)) {
+ setTargetId(result.notificationId)
+ }
+
+ return result
}, [])
const update = useCallback(
- async (options?: UpdateAndroidLiveUpdateOptions) => {
+ async (options?: UpdateAndroidOngoingNotificationOptions) => {
if (!targetId) {
- return
+ return createNotFoundUpdateResult(optionsRef.current?.notificationId ?? 'unknown')
}
const updateOptions = { ...optionsRef.current, ...options }
lastUpdateOptionsRef.current = updateOptions
- await updateAndroidLiveUpdate(targetId, variantsRef.current, updateOptions)
+ return updateAndroidOngoingNotification(targetId, contentRef.current, updateOptions)
},
[targetId]
)
const end = useCallback(async () => {
if (!targetId) {
- return
+ return createNotFoundStopResult(optionsRef.current?.notificationId ?? 'unknown')
}
- await stopAndroidLiveUpdate(targetId)
- setTargetId(null)
+ const result = await stopAndroidOngoingNotification(targetId)
+ if (result.ok || result.reason === 'not_found') {
+ setTargetId(null)
+ }
+ return result
}, [targetId])
useEffect(() => {
- if (!options?.autoStart) {
+ if (!options.autoStart || targetId) {
return
}
- start()
- }, [options?.autoStart, start])
+ void start()
+ }, [options.autoStart, start, targetId])
useEffect(() => {
- if (!options?.autoUpdate) return
- update(lastUpdateOptionsRef.current)
- }, [options?.autoUpdate, update, variants])
+ if (!options.autoUpdate || !targetId) {
+ return
+ }
+
+ void update(lastUpdateOptionsRef.current)
+ }, [content, options.autoUpdate, targetId, update])
return {
start,
@@ -124,121 +233,180 @@ export const useAndroidLiveUpdate = (
/**
* @unstable This API is experimental and may change in future versions.
*
- * Start a new Android Live Update with the provided content variants.
+ * Start a new Android ongoing notification with the provided content.
*
- * @param variants - The Android Live Update content variants to display
- * @param options - Configuration options for the Live Update
- * @returns Promise resolving to the notification ID
+ * @param content - The Android ongoing notification content to display
+ * @param options - Configuration options for the ongoing notification
+ * @returns Promise resolving to the start result
*
* @example
* ```tsx
- * import { VoltraAndroid, startAndroidLiveUpdate } from 'voltra'
- *
- * const notificationId = await startAndroidLiveUpdate({
- * collapsed: Delivery arriving,
- * expanded: ...,
- * channelId: 'delivery_updates',
- * }, {
- * updateName: 'my-live-update',
- * })
+ * import { AndroidOngoingNotification, startAndroidOngoingNotification } from 'voltra/android/client'
+ *
+ * const result = await startAndroidOngoingNotification(
+ * ,
+ * {
+ * notificationId: 'my-ongoing-notification',
+ * channelId: 'delivery_updates',
+ * }
+ * )
+ *
+ * if (result.ok) {
+ * console.log(result.notificationId)
+ * }
* ```
*/
-export const startAndroidLiveUpdate = async (
- variants: AndroidLiveUpdateVariants,
- options?: StartAndroidLiveUpdateOptions
-): Promise => {
- const payload = renderAndroidLiveUpdateToString(variants)
-
- const notificationId = await VoltraModule.startAndroidLiveUpdate(payload, {
- updateName: options?.updateName,
- channelId: options?.channelId || variants.channelId || 'voltra_live_updates',
- })
+export const startAndroidOngoingNotification = async (
+ input: AndroidOngoingNotificationInput,
+ options: StartAndroidOngoingNotificationOptions
+): Promise => {
+ return (await VoltraModule.startAndroidOngoingNotification(
+ serializeAndroidOngoingNotificationInput(input),
+ getStartAndroidOngoingNotificationOptions(input, options)
+ )) as AndroidOngoingNotificationStartResult
+}
- return notificationId
+export const upsertAndroidOngoingNotification = async (
+ input: AndroidOngoingNotificationInput,
+ options: StartAndroidOngoingNotificationOptions
+): Promise => {
+ return (await VoltraModule.upsertAndroidOngoingNotification(
+ serializeAndroidOngoingNotificationInput(input),
+ getStartAndroidOngoingNotificationOptions(input, options)
+ )) as AndroidOngoingNotificationUpsertResult
}
/**
* @unstable This API is experimental and may change in future versions.
*
- * Update an existing Android Live Update with new content.
+ * Update an existing Android ongoing notification with new content.
*
* @param notificationId - The ID of the notification to update
- * @param variants - The new Android Live Update content variants
+ * @param content - The new Android ongoing notification content
* @param options - Update options
*
* @example
* ```tsx
- * import { VoltraAndroid, updateAndroidLiveUpdate } from 'voltra'
+ * import { AndroidOngoingNotification, updateAndroidOngoingNotification } from 'voltra/android/client'
*
- * await updateAndroidLiveUpdate('notification-123', {
- * collapsed: Updated: Delivery arriving,
- * expanded: ...,
- * })
+ * await updateAndroidOngoingNotification(
+ * 'notification-123',
+ *
+ * )
* ```
*/
-export const updateAndroidLiveUpdate = async (
+export const updateAndroidOngoingNotification = async (
notificationId: string,
- variants: AndroidLiveUpdateVariants,
- options?: UpdateAndroidLiveUpdateOptions
-): Promise => {
- const payload = renderAndroidLiveUpdateToString(variants)
+ input: AndroidOngoingNotificationInput,
+ options?: UpdateAndroidOngoingNotificationOptions
+): Promise => {
+ return (await VoltraModule.updateAndroidOngoingNotification(
+ notificationId,
+ serializeAndroidOngoingNotificationInput(input),
+ getFilteredAndroidOngoingNotificationUpdateOptions(options)
+ )) as AndroidOngoingNotificationUpdateResult
+}
+
+export const hasAndroidNotificationPermission = async (): Promise => {
+ if (Platform.OS !== 'android') {
+ return false
+ }
- return VoltraModule.updateAndroidLiveUpdate(notificationId, payload)
+ if (!isAndroidNotificationPermissionRequired()) {
+ return true
+ }
+
+ return PermissionsAndroid.check(NOTIFICATION_PERMISSION)
+}
+
+export const requestAndroidNotificationPermission = async (): Promise => {
+ if (Platform.OS !== 'android') {
+ return false
+ }
+
+ if (!isAndroidNotificationPermissionRequired()) {
+ return true
+ }
+
+ const result = await PermissionsAndroid.request(NOTIFICATION_PERMISSION)
+ return result === PermissionsAndroid.RESULTS.GRANTED
+}
+
+export const openAndroidNotificationSettings = async (): Promise => {
+ if (Platform.OS !== 'android') {
+ return
+ }
+
+ return VoltraModule.openAndroidNotificationSettings()
}
/**
* @unstable This API is experimental and may change in future versions.
*
- * Stop an Android Live Update and dismiss the notification.
+ * Stop an Android ongoing notification and dismiss it.
*
* @param notificationId - The ID of the notification to stop
*
* @example
* ```tsx
- * import { stopAndroidLiveUpdate } from 'voltra'
+ * import { stopAndroidOngoingNotification } from 'voltra'
*
- * await stopAndroidLiveUpdate('notification-123')
+ * await stopAndroidOngoingNotification('notification-123')
* ```
*/
-export const stopAndroidLiveUpdate = async (notificationId: string): Promise => {
- return VoltraModule.stopAndroidLiveUpdate(notificationId)
+export const stopAndroidOngoingNotification = async (
+ notificationId: string
+): Promise => {
+ return (await VoltraModule.stopAndroidOngoingNotification(notificationId)) as AndroidOngoingNotificationStopResult
}
/**
* @unstable This API is experimental and may change in future versions.
*
- * Check if an Android Live Update with the given name is currently active.
+ * Check if an Android ongoing notification with the given identifier is currently active.
*
- * @param updateName - The name of the Live Update to check
+ * @param notificationId - The identifier of the ongoing notification to check
* @returns true if the update is active, false otherwise
*
* @example
* ```tsx
- * import { isAndroidLiveUpdateActive } from 'voltra'
+ * import { isAndroidOngoingNotificationActive } from 'voltra'
*
- * if (isAndroidLiveUpdateActive('my-live-update')) {
+ * if (isAndroidOngoingNotificationActive('my-ongoing-notification')) {
* console.log('Update is running')
* }
* ```
*/
-export const isAndroidLiveUpdateActive = (updateName: string): boolean => {
- return VoltraModule.isAndroidLiveUpdateActive(updateName)
+export const isAndroidOngoingNotificationActive = (notificationId: string): boolean => {
+ return VoltraModule.isAndroidOngoingNotificationActive(notificationId)
+}
+
+export const getAndroidOngoingNotificationStatus = (notificationId: string): AndroidOngoingNotificationStatus => {
+ return VoltraModule.getAndroidOngoingNotificationStatus(notificationId)
}
/**
* @unstable This API is experimental and may change in future versions.
*
- * End all active Android Live Updates.
+ * End all active Android ongoing notifications.
*
- * This function stops and dismisses all currently running Live Updates in the app.
+ * This function stops and dismisses all currently running ongoing notifications in the app.
*
* @example
* ```tsx
- * import { endAllAndroidLiveUpdates } from 'voltra'
+ * import { endAllAndroidOngoingNotifications } from 'voltra'
*
- * await endAllAndroidLiveUpdates()
+ * await endAllAndroidOngoingNotifications()
* ```
*/
-export async function endAllAndroidLiveUpdates(): Promise {
- return VoltraModule.endAllAndroidLiveUpdates()
+export async function endAllAndroidOngoingNotifications(): Promise {
+ return VoltraModule.endAllAndroidOngoingNotifications()
+}
+
+export const canPostPromotedAndroidNotifications = (): boolean => {
+ return VoltraModule.canPostPromotedAndroidNotifications()
+}
+
+export const getAndroidOngoingNotificationCapabilities = (): AndroidOngoingNotificationCapabilities => {
+ return VoltraModule.getAndroidOngoingNotificationCapabilities()
}
diff --git a/packages/android/src/live-update/components.tsx b/packages/android/src/live-update/components.tsx
new file mode 100644
index 00000000..72472231
--- /dev/null
+++ b/packages/android/src/live-update/components.tsx
@@ -0,0 +1,49 @@
+import type { ComponentType } from 'react'
+
+import type {
+ AndroidOngoingNotificationActionProps,
+ AndroidOngoingNotificationBigTextProps,
+ AndroidOngoingNotificationProgressProps,
+} from './types.js'
+
+export const ANDROID_ONGOING_NOTIFICATION_COMPONENT_TAG = Symbol.for('VOLTRA_ANDROID_ONGOING_NOTIFICATION_COMPONENT')
+
+type AndroidOngoingNotificationComponentKind = 'progress' | 'bigText' | 'action'
+
+type AndroidOngoingNotificationComponent> = ComponentType & {
+ displayName: string
+ [ANDROID_ONGOING_NOTIFICATION_COMPONENT_TAG]: AndroidOngoingNotificationComponentKind
+}
+
+const createAndroidOngoingNotificationComponent = >(
+ displayName: string,
+ kind: AndroidOngoingNotificationComponentKind
+): AndroidOngoingNotificationComponent => {
+ const Component = (_props: TProps) => null
+
+ Component.displayName = displayName
+ ;(Component as AndroidOngoingNotificationComponent)[ANDROID_ONGOING_NOTIFICATION_COMPONENT_TAG] = kind
+
+ return Component as AndroidOngoingNotificationComponent
+}
+
+export const Progress = createAndroidOngoingNotificationComponent(
+ 'AndroidOngoingNotification.Progress',
+ 'progress'
+)
+
+export const BigText = createAndroidOngoingNotificationComponent(
+ 'AndroidOngoingNotification.BigText',
+ 'bigText'
+)
+
+export const Action = createAndroidOngoingNotificationComponent(
+ 'AndroidOngoingNotification.Action',
+ 'action'
+)
+
+export const AndroidOngoingNotification = {
+ Progress,
+ BigText,
+ Action,
+} as const
diff --git a/packages/android/src/live-update/renderer.ts b/packages/android/src/live-update/renderer.ts
index 6daf19e8..eedce2f8 100644
--- a/packages/android/src/live-update/renderer.ts
+++ b/packages/android/src/live-update/renderer.ts
@@ -1,51 +1,361 @@
+import { Children, Fragment, isValidElement, type ReactElement, type ReactNode } from 'react'
+
import { getAndroidComponentId } from '../payload/component-ids.js'
-import type { ComponentRegistry } from '../renderer/index.js'
-import { createVoltraRenderer } from '../renderer/index.js'
-import type { AndroidLiveUpdateJson, AndroidLiveUpdateVariants } from './types.js'
+import type {
+ AndroidOngoingNotificationActionPayload,
+ AndroidOngoingNotificationActionProps,
+ AndroidOngoingNotificationBigTextPayload,
+ AndroidOngoingNotificationBigTextProps,
+ AndroidOngoingNotificationContent,
+ AndroidOngoingNotificationPayload,
+ AndroidOngoingNotificationProgressPayload,
+ AndroidOngoingNotificationProgressPoint,
+ AndroidOngoingNotificationProgressProps,
+ AndroidOngoingNotificationProgressSegment,
+} from './types.js'
+
+import { ANDROID_ONGOING_NOTIFICATION_COMPONENT_TAG } from './components.js'
+import type { ImageSource } from '../jsx/Image.js'
+
+void getAndroidComponentId
+
+const PAYLOAD_VERSION = 1 as const
+
+const flattenChildren = (node: ReactNode): ReactNode[] => {
+ if (node === null || node === undefined || typeof node === 'boolean') {
+ return []
+ }
+
+ if (Array.isArray(node)) {
+ return node.flatMap((child) => flattenChildren(child))
+ }
-/**
- * Android component registry that uses Android component ID mappings
- */
-const androidComponentRegistry: ComponentRegistry = {
- getComponentId: (name: string) => getAndroidComponentId(name),
+ return [node]
}
-/**
- * Renders Android Live Update variants to JSON.
- * Uses the Android component registry for component ID lookups.
- */
-export const renderAndroidLiveUpdateToJson = (variants: AndroidLiveUpdateVariants): AndroidLiveUpdateJson => {
- // Create renderer with Android component registry
- const renderer = createVoltraRenderer(androidComponentRegistry)
+const getSingleRootElement = (content: ReactNode): ReactElement> => {
+ const children = flattenChildren(content)
- // Add collapsed notification content
- if (variants.collapsed) {
- renderer.addRootNode('collapsed', variants.collapsed)
+ if (children.length !== 1) {
+ throw new Error('[Voltra] [Android] Ongoing notification content must contain exactly one root element.')
}
- // Add expanded notification content
- if (variants.expanded) {
- renderer.addRootNode('expanded', variants.expanded)
+ const [root] = children
+
+ if (!isValidElement(root)) {
+ throw new Error(
+ '[Voltra] [Android] Ongoing notification content must be a valid AndroidOngoingNotification element.'
+ )
+ }
+
+ if (root.type === Fragment) {
+ return getSingleRootElement((root.props as { children?: ReactNode }).children)
}
- // Render to JSON
- const result = renderer.render() as AndroidLiveUpdateJson
+ return root as ReactElement>
+}
- // Add non-JSX properties
- if (variants.smallIcon) {
- result.smallIcon = variants.smallIcon
+const assertString = (value: unknown, propName: string): string => {
+ if (typeof value !== 'string' || value.length === 0) {
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}" must be a non-empty string.`)
}
- if (variants.channelId) {
- result.channelId = variants.channelId
+ return value
+}
+
+const assertOptionalNonEmptyString = (value: unknown, propName: string): string | undefined => {
+ if (value === undefined) {
+ return undefined
}
- return result
+ if (typeof value !== 'string' || value.length === 0) {
+ throw new Error(
+ `[Voltra] [Android] Ongoing notification prop "${propName}" must be a non-empty string when provided.`
+ )
+ }
+
+ return value
+}
+
+const assertOptionalString = (value: unknown, propName: string): string | undefined => {
+ if (value === undefined) {
+ return undefined
+ }
+
+ if (typeof value !== 'string') {
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}" must be a string.`)
+ }
+
+ return value
+}
+
+const isImageSource = (value: unknown): value is ImageSource => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return false
+ }
+
+ const keys = Object.keys(value)
+ if (keys.length !== 1) {
+ return false
+ }
+
+ if ('assetName' in value) {
+ return typeof value.assetName === 'string' && value.assetName.length > 0
+ }
+
+ if ('base64' in value) {
+ return typeof value.base64 === 'string' && value.base64.length > 0
+ }
+
+ return false
+}
+
+const assertOptionalImageSource = (value: unknown, propName: string): ImageSource | undefined => {
+ if (value === undefined) {
+ return undefined
+ }
+
+ if (!isImageSource(value)) {
+ throw new Error(
+ `[Voltra] [Android] Ongoing notification prop "${propName}" must be an image source with either assetName or base64.`
+ )
+ }
+
+ return value
+}
+
+const assertBoolean = (value: unknown, propName: string): boolean | undefined => {
+ if (value === undefined) {
+ return undefined
+ }
+
+ if (typeof value !== 'boolean') {
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}" must be a boolean.`)
+ }
+
+ return value
+}
+
+const assertFiniteNumber = (value: unknown, propName: string): number => {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}" must be a finite number.`)
+ }
+
+ return value
+}
+
+const assertOptionalColorString = (value: unknown, propName: string): string | undefined => {
+ if (value === undefined) {
+ return undefined
+ }
+
+ if (typeof value !== 'string' || value.trim().length === 0) {
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}" must be a non-empty color string.`)
+ }
+
+ return value
+}
+
+const normalizeProgressSegments = (value: unknown): AndroidOngoingNotificationProgressSegment[] | undefined => {
+ if (value === undefined) {
+ return undefined
+ }
+
+ if (!Array.isArray(value)) {
+ throw new Error('[Voltra] [Android] Ongoing notification prop "segments" must be an array.')
+ }
+
+ return value.map((segment, index) => {
+ if (!segment || typeof segment !== 'object' || Array.isArray(segment)) {
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "segments[${index}]" must be an object.`)
+ }
+
+ const length = assertFiniteNumber((segment as { length?: unknown }).length, `segments[${index}].length`)
+ if (length <= 0) {
+ throw new Error(
+ `[Voltra] [Android] Ongoing notification prop "segments[${index}].length" must be greater than 0.`
+ )
+ }
+
+ return {
+ length,
+ color: assertOptionalColorString((segment as { color?: unknown }).color, `segments[${index}].color`),
+ }
+ })
+}
+
+const normalizeProgressPoints = (value: unknown): AndroidOngoingNotificationProgressPoint[] | undefined => {
+ if (value === undefined) {
+ return undefined
+ }
+
+ if (!Array.isArray(value)) {
+ throw new Error('[Voltra] [Android] Ongoing notification prop "points" must be an array.')
+ }
+
+ return value.map((point, index) => {
+ if (!point || typeof point !== 'object' || Array.isArray(point)) {
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "points[${index}]" must be an object.`)
+ }
+
+ const position = assertFiniteNumber((point as { position?: unknown }).position, `points[${index}].position`)
+ if (position < 0) {
+ throw new Error(
+ `[Voltra] [Android] Ongoing notification prop "points[${index}].position" must be greater than or equal to 0.`
+ )
+ }
+
+ return {
+ position,
+ color: assertOptionalColorString((point as { color?: unknown }).color, `points[${index}].color`),
+ }
+ })
+}
+
+const normalizeWhen = (value: unknown): number | undefined => {
+ if (value === undefined) {
+ return undefined
+ }
+
+ if (value instanceof Date) {
+ const timestamp = value.getTime()
+ if (!Number.isFinite(timestamp)) {
+ throw new Error('[Voltra] [Android] Ongoing notification prop "when" must be a valid Date or timestamp.')
+ }
+
+ return timestamp
+ }
+
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value
+ }
+
+ throw new Error('[Voltra] [Android] Ongoing notification prop "when" must be a valid Date or timestamp.')
+}
+
+const getElementKind = (element: ReactElement>) => {
+ const elementType = element.type as unknown
+
+ return typeof elementType === 'function' || (typeof elementType === 'object' && elementType !== null)
+ ? (elementType as Record)[ANDROID_ONGOING_NOTIFICATION_COMPONENT_TAG]
+ : undefined
+}
+
+const normalizeActionPayload = (
+ props: AndroidOngoingNotificationActionProps
+): AndroidOngoingNotificationActionPayload => {
+ return {
+ title: assertString(props.title, 'title'),
+ deepLinkUrl: assertString(props.deepLinkUrl, 'deepLinkUrl'),
+ icon: assertOptionalImageSource(props.icon, 'icon'),
+ }
+}
+
+const normalizeActions = (value: ReactNode): AndroidOngoingNotificationActionPayload[] | undefined => {
+ const actions: AndroidOngoingNotificationActionPayload[] = []
+
+ Children.forEach(value, (child) => {
+ if (!isValidElement(child)) {
+ return
+ }
+
+ if (child.type === Fragment) {
+ const fragmentChildren = normalizeActions((child.props as { children?: ReactNode }).children)
+ if (fragmentChildren) {
+ actions.push(...fragmentChildren)
+ }
+ return
+ }
+
+ if (getElementKind(child as ReactElement>) !== 'action') {
+ return
+ }
+
+ actions.push(normalizeActionPayload(child.props as AndroidOngoingNotificationActionProps))
+ })
+
+ return actions.length > 0 ? actions : undefined
+}
+
+const normalizeProgressPayload = (
+ props: AndroidOngoingNotificationProgressProps
+): AndroidOngoingNotificationProgressPayload => {
+ const value = assertFiniteNumber(props.value, 'value')
+ const max = assertFiniteNumber(props.max, 'max')
+
+ if (max <= 0) {
+ throw new Error('[Voltra] [Android] Ongoing notification prop "max" must be greater than 0.')
+ }
+
+ if (value < 0 || value > max) {
+ throw new Error('[Voltra] [Android] Ongoing notification prop "value" must be between 0 and max.')
+ }
+
+ return {
+ v: PAYLOAD_VERSION,
+ kind: 'progress',
+ title: assertOptionalNonEmptyString(props.title, 'title'),
+ subText: assertOptionalString(props.subText, 'subText'),
+ text: assertOptionalString(props.text, 'text'),
+ value,
+ max,
+ indeterminate: assertBoolean(props.indeterminate, 'indeterminate'),
+ shortCriticalText: assertOptionalString(props.shortCriticalText, 'shortCriticalText'),
+ when: normalizeWhen(props.when),
+ chronometer: assertBoolean(props.chronometer, 'chronometer'),
+ largeIcon: assertOptionalImageSource(props.largeIcon, 'largeIcon'),
+ progressTrackerIcon: assertOptionalImageSource(props.progressTrackerIcon, 'progressTrackerIcon'),
+ progressStartIcon: assertOptionalImageSource(props.progressStartIcon, 'progressStartIcon'),
+ progressEndIcon: assertOptionalImageSource(props.progressEndIcon, 'progressEndIcon'),
+ segments: normalizeProgressSegments(props.segments),
+ points: normalizeProgressPoints(props.points),
+ actions: normalizeActions(props.children),
+ }
+}
+
+const normalizeBigTextPayload = (
+ props: AndroidOngoingNotificationBigTextProps
+): AndroidOngoingNotificationBigTextPayload => {
+ const text = assertString(props.text, 'text')
+
+ return {
+ v: PAYLOAD_VERSION,
+ kind: 'bigText',
+ title: assertOptionalNonEmptyString(props.title, 'title'),
+ subText: assertOptionalString(props.subText, 'subText'),
+ text,
+ bigText: assertOptionalString(props.bigText, 'bigText') ?? text,
+ shortCriticalText: assertOptionalString(props.shortCriticalText, 'shortCriticalText'),
+ when: normalizeWhen(props.when),
+ chronometer: assertBoolean(props.chronometer, 'chronometer'),
+ largeIcon: assertOptionalImageSource(props.largeIcon, 'largeIcon'),
+ actions: normalizeActions(props.children),
+ }
+}
+
+export const renderAndroidOngoingNotificationPayloadToJson = (
+ content: ReactNode
+): AndroidOngoingNotificationPayload => {
+ const element = getSingleRootElement(content)
+ const kind = getElementKind(element)
+
+ if (kind === 'progress') {
+ return normalizeProgressPayload(element.props as AndroidOngoingNotificationProgressProps)
+ }
+
+ if (kind === 'bigText') {
+ return normalizeBigTextPayload(element.props as AndroidOngoingNotificationBigTextProps)
+ }
+
+ throw new Error(
+ '[Voltra] [Android] Ongoing notification content must use AndroidOngoingNotification.Progress or AndroidOngoingNotification.BigText.'
+ )
+}
+
+export const renderAndroidOngoingNotificationPayload = (content: ReactNode): string => {
+ return JSON.stringify(renderAndroidOngoingNotificationPayloadToJson(content))
}
-/**
- * Renders Android Live Update variants to a JSON string.
- */
-export const renderAndroidLiveUpdateToString = (variants: AndroidLiveUpdateVariants): string => {
- return JSON.stringify(renderAndroidLiveUpdateToJson(variants))
+export const renderAndroidOngoingNotificationContent = (content: AndroidOngoingNotificationContent): string => {
+ return renderAndroidOngoingNotificationPayload(content)
}
diff --git a/packages/android/src/live-update/types.ts b/packages/android/src/live-update/types.ts
index b5c8a9fd..415cd824 100644
--- a/packages/android/src/live-update/types.ts
+++ b/packages/android/src/live-update/types.ts
@@ -1,115 +1,244 @@
import type { ReactNode } from 'react'
-import type { VoltraNodeJson } from '../types.js'
+import type { ImageSource } from '../jsx/Image.js'
+
+export type AndroidOngoingNotificationFallbackBehavior = 'standard' | 'error'
+
+export type AndroidOngoingNotificationCommonDisplayProps = {
+ title?: string
+ subText?: string
+ shortCriticalText?: string
+ when?: Date | number
+ chronometer?: boolean
+}
+
+export type AndroidOngoingNotificationProgressSegment = {
+ length: number
+ color?: string
+}
+
+export type AndroidOngoingNotificationProgressPoint = {
+ position: number
+ color?: string
+}
+
+export type AndroidOngoingNotificationActionProps = {
+ title: string
+ deepLinkUrl: string
+ icon?: ImageSource
+}
+
+export type AndroidOngoingNotificationActionPayload = {
+ title: string
+ deepLinkUrl: string
+ icon?: ImageSource
+}
+
+export type AndroidOngoingNotificationProgressProps = AndroidOngoingNotificationCommonDisplayProps & {
+ text?: string
+ value: number
+ max: number
+ indeterminate?: boolean
+ largeIcon?: ImageSource
+ progressTrackerIcon?: ImageSource
+ progressStartIcon?: ImageSource
+ progressEndIcon?: ImageSource
+ segments?: AndroidOngoingNotificationProgressSegment[]
+ points?: AndroidOngoingNotificationProgressPoint[]
+ children?: ReactNode
+}
+
+export type AndroidOngoingNotificationBigTextProps = AndroidOngoingNotificationCommonDisplayProps & {
+ text: string
+ bigText?: string
+ largeIcon?: ImageSource
+ children?: ReactNode
+}
+
+export type AndroidOngoingNotificationProgressPayload = {
+ v: 1
+ kind: 'progress'
+ title?: string
+ subText?: string
+ text?: string
+ value: number
+ max: number
+ indeterminate?: boolean
+ shortCriticalText?: string
+ when?: number
+ chronometer?: boolean
+ largeIcon?: ImageSource
+ progressTrackerIcon?: ImageSource
+ progressStartIcon?: ImageSource
+ progressEndIcon?: ImageSource
+ segments?: AndroidOngoingNotificationProgressSegment[]
+ points?: AndroidOngoingNotificationProgressPoint[]
+ actions?: AndroidOngoingNotificationActionPayload[]
+}
+
+export type AndroidOngoingNotificationBigTextPayload = {
+ v: 1
+ kind: 'bigText'
+ title?: string
+ subText?: string
+ text: string
+ bigText?: string
+ shortCriticalText?: string
+ when?: number
+ chronometer?: boolean
+ largeIcon?: ImageSource
+ actions?: AndroidOngoingNotificationActionPayload[]
+}
+
+export type AndroidOngoingNotificationPayload =
+ | AndroidOngoingNotificationProgressPayload
+ | AndroidOngoingNotificationBigTextPayload
+
+export type AndroidOngoingNotificationContent = ReactNode
+
+export type AndroidOngoingNotificationInput =
+ | AndroidOngoingNotificationContent
+ | AndroidOngoingNotificationPayload
+ | string
/**
- * Android Live Update variants for Ongoing Notifications.
- *
- * Android ongoing notifications have a simpler structure than iOS Live Activities.
- * There's no Dynamic Island equivalent.
+ * Options for starting an Android ongoing notification.
*/
-export type AndroidLiveUpdateVariants = {
+export type StartAndroidOngoingNotificationOptions = {
/**
- * The collapsed notification content (always visible in the notification shade).
- * Height constraint: ~64dp
+ * A unique identifier for this ongoing notification.
+ * Allows you to rebind to the same notification on app restart.
*/
- collapsed?: ReactNode
+ notificationId?: string
/**
- * The expanded notification content (visible when user expands the notification).
- * Height constraint: up to 256dp
+ * The notification channel ID to use.
+ * The channel must already exist.
*/
- expanded?: ReactNode
+ channelId: string
/**
* Small icon resource name for the notification.
- * Should reference a drawable resource (e.g., 'ic_notification')
+ * Overrides the smallIcon from variants if provided.
*/
smallIcon?: string
/**
- * Notification channel ID (required on Android 8+).
- * The channel must be created before starting the live update.
+ * Deep link URL to open when the notification is tapped.
*/
- channelId?: string
-}
+ deepLinkUrl?: string
-/**
- * Rendered Android Live Update variants to JSON.
- */
-export type AndroidLiveUpdateVariantsJson = {
- /** Payload version - required for remote updates */
- v: number
- /** Shared stylesheet for all variants */
- s?: Record[]
- /** Shared elements for deduplication */
- e?: VoltraNodeJson[]
- /** Collapsed notification content */
- collapsed?: VoltraNodeJson
- /** Expanded notification content */
- expanded?: VoltraNodeJson
- /** Small icon resource name */
- smallIcon?: string
- /** Notification channel ID */
- channelId?: string
-}
-
-/**
- * JSON representation of Android live update variants for rendering
- */
-export type AndroidLiveUpdateJson = AndroidLiveUpdateVariantsJson
-
-/**
- * Options for starting an Android Live Update
- */
-export type StartAndroidLiveUpdateOptions = {
/**
- * A unique name for this live update.
- * Allows you to rebind to the same notification on app restart.
+ * Whether to request promoted ongoing presentation when supported.
*/
- updateName?: string
+ requestPromotedOngoing?: boolean
/**
- * The notification channel ID to use.
- * Overrides the channelId from variants if provided.
+ * Behavior when promotion is requested but unavailable.
*/
- channelId?: string
+ fallbackBehavior?: AndroidOngoingNotificationFallbackBehavior
}
/**
- * Options for updating an Android Live Update
+ * Options for updating an Android ongoing notification.
*/
-export type UpdateAndroidLiveUpdateOptions = {
- // Currently no additional options, but keeping for future extensibility
-}
+export type UpdateAndroidOngoingNotificationOptions = Omit<
+ Partial,
+ 'notificationId'
+>
/**
- * Options for the useAndroidLiveUpdate hook
+ * Options for the useAndroidOngoingNotification hook.
*/
-export type UseAndroidLiveUpdateOptions = {
+export type UseAndroidOngoingNotificationOptions = StartAndroidOngoingNotificationOptions & {
/**
- * A unique name for this live update.
- * Allows you to rebind to the same notification on app restart.
- */
- updateName?: string
-
- /**
- * Automatically start the live update when the component mounts.
+ * Automatically start the ongoing notification when the component mounts.
*/
autoStart?: boolean
/**
- * Automatically update the live update when the component updates.
+ * Automatically update the ongoing notification when the component updates.
*/
autoUpdate?: boolean
}
+export type AndroidOngoingNotificationCapabilities = {
+ apiLevel: number
+ notificationsEnabled: boolean
+ supportsPromotedNotifications: boolean
+ canPostPromotedNotifications: boolean
+ canRequestPromotedOngoing: boolean
+}
+
+export type AndroidOngoingNotificationStatus = {
+ isActive: boolean
+ isDismissed: boolean
+ isPromoted?: boolean
+ hasPromotableCharacteristics?: boolean
+}
+
+export type AndroidOngoingNotificationStartResult =
+ | {
+ ok: true
+ notificationId: string
+ action: 'started'
+ reason?: undefined
+ }
+ | {
+ ok: false
+ notificationId: string
+ action?: undefined
+ reason: 'already_exists'
+ }
+
+export type AndroidOngoingNotificationUpdateResult =
+ | {
+ ok: true
+ notificationId: string
+ action: 'updated'
+ reason?: undefined
+ }
+ | {
+ ok: false
+ notificationId: string
+ action?: undefined
+ reason: 'not_found' | 'dismissed'
+ }
+
+export type AndroidOngoingNotificationUpsertResult =
+ | {
+ ok: true
+ notificationId: string
+ action: 'started' | 'updated'
+ reason?: undefined
+ }
+ | {
+ ok: false
+ notificationId: string
+ action?: undefined
+ reason: 'already_exists' | 'dismissed'
+ }
+
+export type AndroidOngoingNotificationStopResult =
+ | {
+ ok: true
+ notificationId: string
+ action: 'stopped'
+ reason?: undefined
+ }
+ | {
+ ok: false
+ notificationId: string
+ action?: undefined
+ reason: 'not_found'
+ }
+
/**
- * Result from the useAndroidLiveUpdate hook
+ * Result from the useAndroidOngoingNotification hook.
*/
-export type UseAndroidLiveUpdateResult = {
- start: (options?: StartAndroidLiveUpdateOptions) => Promise
- update: (options?: UpdateAndroidLiveUpdateOptions) => Promise
- end: () => Promise
+export type UseAndroidOngoingNotificationResult = {
+ start: (options?: Partial) => Promise
+ update: (options?: UpdateAndroidOngoingNotificationOptions) => Promise
+ end: () => Promise
isActive: boolean
}
diff --git a/packages/android/src/server.ts b/packages/android/src/server.ts
index 30fc036e..f84c5da2 100644
--- a/packages/android/src/server.ts
+++ b/packages/android/src/server.ts
@@ -1,3 +1,19 @@
-export { renderAndroidLiveUpdateToString } from './live-update/renderer.js'
+export { AndroidOngoingNotification } from './live-update/components.js'
+export {
+ renderAndroidOngoingNotificationPayload,
+ renderAndroidOngoingNotificationPayloadToJson,
+} from './live-update/renderer.js'
+export type {
+ AndroidOngoingNotificationActionPayload,
+ AndroidOngoingNotificationActionProps,
+ AndroidOngoingNotificationBigTextPayload,
+ AndroidOngoingNotificationBigTextProps,
+ AndroidOngoingNotificationContent,
+ AndroidOngoingNotificationPayload,
+ AndroidOngoingNotificationProgressPayload,
+ AndroidOngoingNotificationProgressPoint,
+ AndroidOngoingNotificationProgressProps,
+ AndroidOngoingNotificationProgressSegment,
+} from './live-update/types.js'
export { renderAndroidWidgetToString } from './widgets/renderer.js'
export type { AndroidColorValue, AndroidDynamicColorRole, AndroidDynamicColorToken } from './dynamic-colors.js'
diff --git a/packages/expo-plugin/src/android/index.ts b/packages/expo-plugin/src/android/index.ts
index 72352db9..d0778129 100644
--- a/packages/expo-plugin/src/android/index.ts
+++ b/packages/expo-plugin/src/android/index.ts
@@ -14,7 +14,7 @@ import { configureAndroidManifest } from './manifest'
* 3. Configure AndroidManifest (receiver entries)
*/
export const withAndroid: ConfigPlugin = (config, props) => {
- const { widgets, userImagesPath, fonts } = props
+ const { enableNotifications, widgets, userImagesPath, fonts } = props
if (!config.android?.package) {
throw new Error(
@@ -33,6 +33,6 @@ export const withAndroid: ConfigPlugin = (config, props) =>
[generateAndroidWidgetFiles, { widgets, userImagesPath, fonts }],
// 2. Configure AndroidManifest (must run after files are generated)
- [configureAndroidManifest, { widgets }],
+ [configureAndroidManifest, { enableNotifications, widgets }],
])
}
diff --git a/packages/expo-plugin/src/android/manifest.ts b/packages/expo-plugin/src/android/manifest.ts
index ec0b2717..51c97002 100644
--- a/packages/expo-plugin/src/android/manifest.ts
+++ b/packages/expo-plugin/src/android/manifest.ts
@@ -4,6 +4,7 @@ import { AndroidConfig } from 'expo/config-plugins'
import type { AndroidWidgetConfig } from '../types'
export interface ConfigureAndroidManifestProps {
+ enableNotifications?: boolean
widgets: AndroidWidgetConfig[]
}
@@ -16,16 +17,54 @@ export interface ConfigureAndroidManifestProps {
* - Widget provider metadata reference
*/
export const configureAndroidManifest: ConfigPlugin = (config, props) => {
- const { widgets } = props
+ const { enableNotifications, widgets } = props
return withAndroidManifest(config, (config) => {
const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults) as any
+
+ const usesPermissions = (config.modResults.manifest['uses-permission'] || []) as any[]
+ const ensurePermission = (permissionName: string) => {
+ const exists = usesPermissions.some((permission) => permission.$?.['android:name'] === permissionName)
+ if (!exists) {
+ usesPermissions.push({
+ $: {
+ 'android:name': permissionName,
+ },
+ })
+ }
+ }
+
+ if (enableNotifications) {
+ ensurePermission('android.permission.POST_NOTIFICATIONS')
+ ensurePermission('android.permission.POST_PROMOTED_NOTIFICATIONS')
+ }
+
+ config.modResults.manifest['uses-permission'] = usesPermissions
+
+ const existingReceivers = (mainApplication.receiver || []) as any[]
+ const ongoingNotificationReceiverName = 'voltra.VoltraOngoingNotificationDismissedReceiver'
+
+ if (enableNotifications) {
+ const hasOngoingNotificationReceiver = existingReceivers.some(
+ (receiver: any) => receiver.$?.['android:name'] === ongoingNotificationReceiverName
+ )
+
+ if (!hasOngoingNotificationReceiver) {
+ existingReceivers.push({
+ $: {
+ 'android:name': ongoingNotificationReceiverName,
+ 'android:exported': 'false',
+ },
+ })
+ mainApplication.receiver = existingReceivers
+ }
+ }
+
// Add a receiver for each widget
for (const widget of widgets) {
const receiverClassName = `.widget.VoltraWidget_${widget.id}Receiver`
// Check if receiver already exists
- const existingReceivers = (mainApplication.receiver || []) as any[]
const alreadyExists = existingReceivers.some(
(receiver: any) => receiver.$?.['android:name'] === receiverClassName
)
diff --git a/packages/expo-plugin/src/index.ts b/packages/expo-plugin/src/index.ts
index 87de395d..50b7f218 100644
--- a/packages/expo-plugin/src/index.ts
+++ b/packages/expo-plugin/src/index.ts
@@ -66,6 +66,7 @@ const withVoltra: VoltraConfigPlugin = (config, props = {}) => {
// Apply Android configuration (files, manifest)
if (props.android) {
config = withAndroid(config, {
+ enableNotifications: props.android.enableNotifications,
widgets: props.android.widgets ?? [],
...(props?.fonts ? { fonts: props.fonts } : {}),
})
diff --git a/packages/expo-plugin/src/types.ts b/packages/expo-plugin/src/types.ts
index 056dd583..b9883a76 100644
--- a/packages/expo-plugin/src/types.ts
+++ b/packages/expo-plugin/src/types.ts
@@ -198,6 +198,11 @@ export interface AndroidWidgetServerUpdateConfig {
* Android-specific plugin configuration
*/
export interface AndroidPluginConfig {
+ /**
+ * Enable Android notification-related manifest plumbing used by Voltra features
+ * such as Live Updates.
+ */
+ enableNotifications?: boolean
/**
* Android home screen widgets
* Separate from iOS widgets to allow platform-specific configurations
@@ -285,6 +290,7 @@ export interface IOSPluginProps {
* Props passed to Android-related plugins
*/
export interface AndroidPluginProps {
+ enableNotifications?: boolean
widgets: AndroidWidgetConfig[]
userImagesPath?: string
fonts?: string[]
diff --git a/packages/expo-plugin/src/validation.ts b/packages/expo-plugin/src/validation.ts
index 493f982e..30f4884f 100644
--- a/packages/expo-plugin/src/validation.ts
+++ b/packages/expo-plugin/src/validation.ts
@@ -216,6 +216,10 @@ export function validateProps(props: ConfigPluginProps): void {
throw new Error('android configuration must be an object')
}
+ if (props.android.enableNotifications !== undefined && typeof props.android.enableNotifications !== 'boolean') {
+ throw new Error('android.enableNotifications must be a boolean')
+ }
+
if (props.android.widgets !== undefined) {
if (!Array.isArray(props.android.widgets)) {
throw new Error('android.widgets must be an array')
diff --git a/packages/voltra/android/src/main/AndroidManifest.xml b/packages/voltra/android/src/main/AndroidManifest.xml
index 97f6aec8..9fc15d0b 100644
--- a/packages/voltra/android/src/main/AndroidManifest.xml
+++ b/packages/voltra/android/src/main/AndroidManifest.xml
@@ -1,6 +1,4 @@
-
-
-
\ No newline at end of file
+
diff --git a/packages/voltra/android/src/main/java/voltra/VoltraModule.kt b/packages/voltra/android/src/main/java/voltra/VoltraModule.kt
index 13245da4..fb7c1e83 100644
--- a/packages/voltra/android/src/main/java/voltra/VoltraModule.kt
+++ b/packages/voltra/android/src/main/java/voltra/VoltraModule.kt
@@ -67,54 +67,151 @@ class VoltraModule : Module() {
eventBusUnsubscribe = null
}
- // Android Live Update APIs
+ // Android ongoing notification APIs
- AsyncFunction("startAndroidLiveUpdate") {
+ AsyncFunction("startAndroidOngoingNotification") {
payload: String,
options: Map,
->
- Log.d(TAG, "startAndroidLiveUpdate called")
+ Log.d(TAG, "startAndroidOngoingNotification called")
- val updateName = options["updateName"] as? String
- val channelId = options["channelId"] as? String ?: "voltra_live_updates"
-
- Log.d(TAG, "updateName=$updateName, channelId=$channelId")
+ val ongoingNotificationOptions =
+ AndroidOngoingNotificationOptions(
+ notificationId = options["notificationId"] as? String,
+ channelId = options["channelId"] as? String,
+ smallIcon = options["smallIcon"] as? String,
+ deepLinkUrl = options["deepLinkUrl"] as? String,
+ requestPromotedOngoing = options["requestPromotedOngoing"] as? Boolean,
+ fallbackBehavior = options["fallbackBehavior"] as? String,
+ )
val result =
runBlocking {
- notificationManager.startLiveUpdate(payload, updateName, channelId)
+ notificationManager.startOngoingNotification(payload, ongoingNotificationOptions)
}
- Log.d(TAG, "startAndroidLiveUpdate returning: $result")
- result
+ Log.d(TAG, "startAndroidOngoingNotification returning: $result")
+ mapOf(
+ "ok" to result.ok,
+ "notificationId" to result.notificationId,
+ "action" to result.action,
+ "reason" to result.reason,
+ )
}
- AsyncFunction("updateAndroidLiveUpdate") {
+ AsyncFunction("updateAndroidOngoingNotification") {
notificationId: String,
payload: String,
+ options: Map?,
->
- Log.d(TAG, "updateAndroidLiveUpdate called with notificationId=$notificationId")
+ Log.d(TAG, "updateAndroidOngoingNotification called with notificationId=$notificationId")
- runBlocking {
- notificationManager.updateLiveUpdate(notificationId, payload)
- }
+ val ongoingNotificationOptions =
+ AndroidOngoingNotificationOptions(
+ channelId = options?.get("channelId") as? String,
+ smallIcon = options?.get("smallIcon") as? String,
+ deepLinkUrl = options?.get("deepLinkUrl") as? String,
+ requestPromotedOngoing = options?.get("requestPromotedOngoing") as? Boolean,
+ fallbackBehavior = options?.get("fallbackBehavior") as? String,
+ )
+
+ val result =
+ runBlocking {
+ notificationManager.updateOngoingNotification(
+ notificationId,
+ payload,
+ ongoingNotificationOptions,
+ )
+ }
+
+ Log.d(TAG, "updateAndroidOngoingNotification returning: $result")
+ mapOf(
+ "ok" to result.ok,
+ "notificationId" to result.notificationId,
+ "action" to result.action,
+ "reason" to result.reason,
+ )
+ }
+
+ AsyncFunction("upsertAndroidOngoingNotification") {
+ payload: String,
+ options: Map,
+ ->
+
+ Log.d(TAG, "upsertAndroidOngoingNotification called")
+
+ val ongoingNotificationOptions =
+ AndroidOngoingNotificationOptions(
+ notificationId = options["notificationId"] as? String,
+ channelId = options["channelId"] as? String,
+ smallIcon = options["smallIcon"] as? String,
+ deepLinkUrl = options["deepLinkUrl"] as? String,
+ requestPromotedOngoing = options["requestPromotedOngoing"] as? Boolean,
+ fallbackBehavior = options["fallbackBehavior"] as? String,
+ )
+
+ val result =
+ runBlocking {
+ notificationManager.upsertOngoingNotification(payload, ongoingNotificationOptions)
+ }
+
+ Log.d(TAG, "upsertAndroidOngoingNotification returning: $result")
+ mapOf(
+ "ok" to result.ok,
+ "notificationId" to result.notificationId,
+ "action" to result.action,
+ "reason" to result.reason,
+ )
+ }
+
+ AsyncFunction("stopAndroidOngoingNotification") { notificationId: String ->
+ Log.d(TAG, "stopAndroidOngoingNotification called with notificationId=$notificationId")
+ val result = notificationManager.stopOngoingNotification(notificationId)
+ mapOf(
+ "ok" to result.ok,
+ "notificationId" to result.notificationId,
+ "action" to result.action,
+ "reason" to result.reason,
+ )
+ }
+
+ Function("isAndroidOngoingNotificationActive") { notificationId: String ->
+ notificationManager.isOngoingNotificationActive(notificationId)
+ }
+
+ Function("getAndroidOngoingNotificationStatus") { notificationId: String ->
+ val status = notificationManager.getOngoingNotificationStatus(notificationId)
+ mapOf(
+ "isActive" to status.isActive,
+ "isDismissed" to status.isDismissed,
+ "isPromoted" to status.isPromoted,
+ "hasPromotableCharacteristics" to status.hasPromotableCharacteristics,
+ )
+ }
- Log.d(TAG, "updateAndroidLiveUpdate completed")
+ AsyncFunction("endAllAndroidOngoingNotifications") {
+ notificationManager.endAllOngoingNotifications()
}
- AsyncFunction("stopAndroidLiveUpdate") { notificationId: String ->
- Log.d(TAG, "stopAndroidLiveUpdate called with notificationId=$notificationId")
- notificationManager.stopLiveUpdate(notificationId)
+ Function("canPostPromotedAndroidNotifications") {
+ notificationManager.canPostPromotedAndroidNotifications()
}
- Function("isAndroidLiveUpdateActive") { updateName: String ->
- notificationManager.isLiveUpdateActive(updateName)
+ Function("getAndroidOngoingNotificationCapabilities") {
+ val capabilities = notificationManager.getOngoingNotificationCapabilities()
+ mapOf(
+ "apiLevel" to capabilities.apiLevel,
+ "notificationsEnabled" to capabilities.notificationsEnabled,
+ "supportsPromotedNotifications" to capabilities.supportsPromotedNotifications,
+ "canPostPromotedNotifications" to capabilities.canPostPromotedNotifications,
+ "canRequestPromotedOngoing" to capabilities.canRequestPromotedOngoing,
+ )
}
- AsyncFunction("endAllAndroidLiveUpdates") {
- notificationManager.endAllLiveUpdates()
+ AsyncFunction("openAndroidNotificationSettings") {
+ notificationManager.openPromotedNotificationSettings()
}
// Android Widget APIs
diff --git a/packages/voltra/android/src/main/java/voltra/VoltraNotificationManager.kt b/packages/voltra/android/src/main/java/voltra/VoltraNotificationManager.kt
index 85c961d9..363f1fbc 100644
--- a/packages/voltra/android/src/main/java/voltra/VoltraNotificationManager.kt
+++ b/packages/voltra/android/src/main/java/voltra/VoltraNotificationManager.kt
@@ -1,157 +1,731 @@
package voltra
-import android.app.NotificationChannel
+import android.app.Notification
+import android.app.Notification.BigTextStyle
+import android.app.Notification.Builder
import android.app.NotificationManager
+import android.app.PendingIntent
import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.BitmapFactory
+import android.graphics.drawable.Icon
+import android.net.Uri
import android.os.Build
+import android.provider.Settings
import android.util.Log
-import androidx.core.app.NotificationCompat
+import androidx.compose.ui.graphics.toArgb
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import voltra.glance.RemoteViewsGenerator
-import voltra.parsing.VoltraPayloadParser
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.atomic.AtomicInteger
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import voltra.images.VoltraImageManager
+import voltra.ongoingnotification.AndroidOngoingNotificationActionPayload
+import voltra.ongoingnotification.AndroidOngoingNotificationBigTextPayload
+import voltra.ongoingnotification.AndroidOngoingNotificationImageSource
+import voltra.ongoingnotification.AndroidOngoingNotificationPayload
+import voltra.ongoingnotification.AndroidOngoingNotificationPayloadParser
+import voltra.ongoingnotification.AndroidOngoingNotificationProgressPayload
+import voltra.ongoingnotification.AndroidOngoingNotificationProgressPointPayload
+import voltra.ongoingnotification.AndroidOngoingNotificationProgressSegmentPayload
+import voltra.ongoingnotification.AndroidOngoingNotificationRecord
+import voltra.styling.JSColorParser
+import voltra.styling.VoltraColorValue
+
+private enum class AndroidOngoingNotificationFallbackBehavior {
+ STANDARD,
+ ERROR,
+}
+
+data class AndroidOngoingNotificationOptions(
+ val notificationId: String? = null,
+ val channelId: String? = null,
+ val smallIcon: String? = null,
+ val deepLinkUrl: String? = null,
+ val requestPromotedOngoing: Boolean? = null,
+ val fallbackBehavior: String? = null,
+)
+
+data class AndroidOngoingNotificationCapabilities(
+ val apiLevel: Int,
+ val notificationsEnabled: Boolean,
+ val supportsPromotedNotifications: Boolean,
+ val canPostPromotedNotifications: Boolean,
+ val canRequestPromotedOngoing: Boolean,
+)
+
+data class AndroidOngoingNotificationStatus(
+ val isActive: Boolean,
+ val isDismissed: Boolean,
+ val isPromoted: Boolean? = null,
+ val hasPromotableCharacteristics: Boolean? = null,
+)
+
+data class AndroidOngoingNotificationStartResult(
+ val ok: Boolean,
+ val notificationId: String,
+ val action: String? = null,
+ val reason: String? = null,
+)
+
+data class AndroidOngoingNotificationUpdateResult(
+ val ok: Boolean,
+ val notificationId: String,
+ val action: String? = null,
+ val reason: String? = null,
+)
+
+data class AndroidOngoingNotificationUpsertResult(
+ val ok: Boolean,
+ val notificationId: String,
+ val action: String? = null,
+ val reason: String? = null,
+)
+
+data class AndroidOngoingNotificationStopResult(
+ val ok: Boolean,
+ val notificationId: String,
+ val action: String? = null,
+ val reason: String? = null,
+)
class VoltraNotificationManager(
- private val context: Context,
+ context: Context,
) {
companion object {
private const val TAG = "VoltraNotificationMgr"
+ private const val PREFS_NAME = "voltra_ongoing_notifications"
+ private const val KEY_RECORDS = "records"
+ private const val KEY_NEXT_NOTIFICATION_ID = "next_notification_id"
+ private const val DEFAULT_NOTIFICATION_ID = 10000
+ private const val PROMOTED_PERMISSION = "android.permission.POST_PROMOTED_NOTIFICATIONS"
+ private const val EXTRA_REQUEST_PROMOTED_ONGOING = "android.requestPromotedOngoing"
+ const val EXTRA_NOTIFICATION_ID = "voltra.extra.NOTIFICATION_ID"
+
+ private val json =
+ Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+
+ fun markDismissed(
+ context: Context,
+ notificationId: String,
+ ) {
+ val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val records = readRecords(prefs).toMutableMap()
+ val record = records[notificationId] ?: return
+
+ records[notificationId] =
+ record.copy(
+ active = false,
+ dismissed = true,
+ )
+ writeRecords(prefs, records)
+ Log.d(TAG, "Marked ongoing notification as dismissed: $notificationId")
+ }
+
+ private fun readRecords(
+ prefs: android.content.SharedPreferences,
+ ): Map {
+ val raw = prefs.getString(KEY_RECORDS, null) ?: return emptyMap()
+ return try {
+ json.decodeFromString