diff --git a/example/app.json b/example/app.json
index e830fe2..d19aa8a 100644
--- a/example/app.json
+++ b/example/app.json
@@ -24,6 +24,9 @@
{
"groupIdentifier": "group.callstackincubator.voltraexample",
"enablePushNotifications": true,
+ "liveActivity": {
+ "supplementalActivityFamilies": ["small"]
+ },
"widgets": [
{
"id": "weather",
diff --git a/example/components/live-activities/SupplementalFamiliesUI.tsx b/example/components/live-activities/SupplementalFamiliesUI.tsx
new file mode 100644
index 0000000..9b59bb7
--- /dev/null
+++ b/example/components/live-activities/SupplementalFamiliesUI.tsx
@@ -0,0 +1,51 @@
+import React from 'react'
+import { Voltra } from 'voltra'
+
+export function SupplementalFamiliesLockScreen() {
+ return (
+
+
+ Lock Screen
+
+
+ )
+}
+
+export function SupplementalFamiliesSmall() {
+ return (
+
+ Watch
+ watchOS / CarPlay
+
+ )
+}
+
+export function SupplementalFamiliesCompactLeading() {
+ return (
+
+ L
+
+ )
+}
+
+export function SupplementalFamiliesCompactTrailing() {
+ return (
+
+ R
+
+ )
+}
+
+export function SupplementalFamiliesMinimal() {
+ return (
+
+
+
+ )
+}
diff --git a/example/screens/live-activities/LiveActivitiesScreen.tsx b/example/screens/live-activities/LiveActivitiesScreen.tsx
index cca3f62..b1ed401 100644
--- a/example/screens/live-activities/LiveActivitiesScreen.tsx
+++ b/example/screens/live-activities/LiveActivitiesScreen.tsx
@@ -12,11 +12,12 @@ import CompassLiveActivity from '~/screens/live-activities/CompassLiveActivity'
import FlightLiveActivity from '~/screens/live-activities/FlightLiveActivity'
import LiquidGlassLiveActivity from '~/screens/live-activities/LiquidGlassLiveActivity'
import MusicPlayerLiveActivity from '~/screens/live-activities/MusicPlayerLiveActivity'
+import SupplementalFamiliesLiveActivity from '~/screens/live-activities/SupplementalFamiliesLiveActivity'
import WorkoutLiveActivity from '~/screens/live-activities/WorkoutLiveActivity'
import { LiveActivityExampleComponentRef } from './types'
-type ActivityKey = 'basic' | 'stylesheet' | 'glass' | 'flight' | 'workout' | 'compass'
+type ActivityKey = 'basic' | 'stylesheet' | 'glass' | 'flight' | 'workout' | 'compass' | 'supplementalFamilies'
const ACTIVITY_METADATA: Record = {
basic: {
@@ -43,9 +44,22 @@ const ACTIVITY_METADATA: Record(null)
@@ -65,6 +80,7 @@ export default function LiveActivitiesScreen() {
const flightRef = useRef(null)
const workoutRef = useRef(null)
const compassRef = useRef(null)
+ const supplementalFamiliesRef = useRef(null)
const activityRefs = useMemo(
() => ({
@@ -74,6 +90,7 @@ export default function LiveActivitiesScreen() {
flight: flightRef,
workout: workoutRef,
compass: compassRef,
+ supplementalFamilies: supplementalFamiliesRef,
}),
[]
)
@@ -109,6 +126,10 @@ export default function LiveActivitiesScreen() {
(isActive: boolean) => handleStatusChange('compass', isActive),
[handleStatusChange]
)
+ const handleSupplementalFamiliesStatusChange = useCallback(
+ (isActive: boolean) => handleStatusChange('supplementalFamilies', isActive),
+ [handleStatusChange]
+ )
const handleStart = async (key: ActivityKey) => {
await activityRefs[key].current?.start?.().catch(console.error)
@@ -187,6 +208,10 @@ export default function LiveActivitiesScreen() {
+
)
diff --git a/example/screens/live-activities/SupplementalFamiliesLiveActivity.tsx b/example/screens/live-activities/SupplementalFamiliesLiveActivity.tsx
new file mode 100644
index 0000000..1237bdb
--- /dev/null
+++ b/example/screens/live-activities/SupplementalFamiliesLiveActivity.tsx
@@ -0,0 +1,56 @@
+import React, { forwardRef, useEffect, useImperativeHandle } from 'react'
+import { useLiveActivity } from 'voltra/client'
+
+import {
+ SupplementalFamiliesCompactLeading,
+ SupplementalFamiliesCompactTrailing,
+ SupplementalFamiliesLockScreen,
+ SupplementalFamiliesMinimal,
+ SupplementalFamiliesSmall,
+} from '../../components/live-activities/SupplementalFamiliesUI'
+import { LiveActivityExampleComponent } from './types'
+
+const SupplementalFamiliesLiveActivity: LiveActivityExampleComponent = forwardRef(
+ ({ autoUpdate = true, autoStart = false, onIsActiveChange }, ref) => {
+ const { start, update, end, isActive } = useLiveActivity(
+ {
+ lockScreen: {
+ content: ,
+ },
+ supplementalActivityFamilies: {
+ small: ,
+ },
+ island: {
+ keylineTint: '#10B981',
+ compact: {
+ leading: ,
+ trailing: ,
+ },
+ minimal: ,
+ },
+ },
+ {
+ activityName: 'supplemental-families-demo',
+ autoUpdate,
+ autoStart,
+ deepLinkUrl: '/voltraui/supplemental-families-demo',
+ }
+ )
+
+ useEffect(() => {
+ onIsActiveChange?.(isActive)
+ }, [isActive, onIsActiveChange])
+
+ useImperativeHandle(ref, () => ({
+ start,
+ update,
+ end,
+ }))
+
+ return null
+ }
+)
+
+SupplementalFamiliesLiveActivity.displayName = 'SupplementalFamiliesLiveActivity'
+
+export default SupplementalFamiliesLiveActivity
diff --git a/ios/app/VoltraActivity.swift b/ios/app/VoltraActivity.swift
new file mode 100644
index 0000000..0c9424f
--- /dev/null
+++ b/ios/app/VoltraActivity.swift
@@ -0,0 +1,5 @@
+import ActivityKit
+import Foundation
+
+/// Type alias for Live Activity with unified attributes
+public typealias VoltraActivity = Activity
diff --git a/ios/app/VoltraLiveActivityService.swift b/ios/app/VoltraLiveActivityService.swift
index 3fb51e9..76ba7fb 100644
--- a/ios/app/VoltraLiveActivityService.swift
+++ b/ios/app/VoltraLiveActivityService.swift
@@ -113,13 +113,14 @@ public class VoltraLiveActivityService {
return Array(Activity.activities)
}
- /// Get the latest (most recently created) activity
- public func getLatestActivity() -> Activity? {
+ /// Get the latest (most recently created) activity across both types
+ public func getLatestActivity() -> VoltraActivity? {
guard Self.isSupported() else { return nil }
- return Activity.activities.last
+ let allActivities = getAllActivities()
+ return allActivities.last
}
- /// Check if an activity with the given name exists
+ /// Check if an activity with the given name exists across both types
public func isActivityActive(name: String) -> Bool {
findActivity(byName: name) != nil
}
@@ -153,7 +154,7 @@ public class VoltraLiveActivityService {
let initialState = try VoltraAttributes.ContentState(uiJsonData: request.jsonString)
// Request the activity
- let activity = try Activity.request(
+ _ = try Activity.request(
attributes: attributes,
content: .init(
state: initialState,
@@ -174,7 +175,7 @@ public class VoltraLiveActivityService {
/// - request: Parameters for updating the activity
/// - Throws: Error if update fails
public func updateActivity(
- _ activity: Activity,
+ _ activity: VoltraActivity,
request: UpdateActivityRequest
) async throws {
guard Self.isSupported() else {
@@ -212,7 +213,7 @@ public class VoltraLiveActivityService {
/// - Parameter activity: The activity to end
/// - Parameter dismissalPolicy: How the activity should be dismissed
public func endActivity(
- _ activity: Activity,
+ _ activity: VoltraActivity,
dismissalPolicy: ActivityUIDismissalPolicy = .immediate
) async {
guard Self.isSupported() else { return }
@@ -254,6 +255,101 @@ public class VoltraLiveActivityService {
await endActivity(activity)
}
}
+
+ // MARK: - Monitoring
+
+ private var monitoredActivityIds: Set = []
+ private var monitoringTasks: [Task] = []
+
+ /// Start monitoring all Live Activities and Push Tokens
+ public func startMonitoring(enablePush: Bool) {
+ guard Self.isSupported() else { return }
+
+ // 1. Monitor Push-to-Start Tokens
+ if enablePush {
+ if #available(iOS 17.2, *) {
+ startPushToStartTokenObservation()
+ }
+ }
+
+ // 2. Monitor Live Activity Updates (Creation & Lifecycle)
+ startActivityUpdatesObservation(enablePush: enablePush)
+ }
+
+ /// Stop all monitoring tasks
+ public func stopMonitoring() {
+ monitoredActivityIds.removeAll()
+ monitoringTasks.forEach { $0.cancel() }
+ monitoringTasks.removeAll()
+ }
+
+ @available(iOS 17.2, *)
+ private func startPushToStartTokenObservation() {
+ if let initialTokenData = Activity.pushToStartToken {
+ let token = initialTokenData.hexString
+ VoltraEventBus.shared.send(.pushToStartTokenReceived(token: token))
+ }
+ let task = Task {
+ for await tokenData in Activity.pushToStartTokenUpdates {
+ let token = tokenData.hexString
+ VoltraEventBus.shared.send(.pushToStartTokenReceived(token: token))
+ }
+ }
+ monitoringTasks.append(task)
+ }
+
+ private func startActivityUpdatesObservation(enablePush: Bool) {
+ // 1. Handle currently existing activities
+ for activity in Activity.activities {
+ monitorActivity(activity, enablePush: enablePush)
+ }
+
+ // 2. Listen for NEW activities
+ let updatesTask = Task {
+ for await newActivity in Activity.activityUpdates {
+ monitorActivity(newActivity, enablePush: enablePush)
+ }
+ }
+ monitoringTasks.append(updatesTask)
+ }
+
+ /// Set up observers for an activity's lifecycle
+ private func monitorActivity(_ activity: Activity, enablePush: Bool) {
+ let activityId = activity.id
+
+ // Avoid duplicate monitoring
+ guard !monitoredActivityIds.contains(activityId) else { return }
+ monitoredActivityIds.insert(activityId)
+
+ // Lifecycle state changes
+ let stateTask = Task {
+ for await state in activity.activityStateUpdates {
+ VoltraEventBus.shared.send(
+ .stateChange(
+ activityName: activity.attributes.name,
+ state: String(describing: state)
+ )
+ )
+ }
+ }
+ monitoringTasks.append(stateTask)
+
+ // Push token updates
+ if enablePush {
+ let tokenTask = Task {
+ for await pushTokenData in activity.pushTokenUpdates {
+ let pushTokenString = pushTokenData.hexString
+ VoltraEventBus.shared.send(
+ .tokenReceived(
+ activityName: activity.attributes.name,
+ pushToken: pushTokenString
+ )
+ )
+ }
+ }
+ monitoringTasks.append(tokenTask)
+ }
+ }
}
// MARK: - Errors
diff --git a/ios/app/VoltraModule.swift b/ios/app/VoltraModule.swift
index 219b90d..98e8057 100644
--- a/ios/app/VoltraModule.swift
+++ b/ios/app/VoltraModule.swift
@@ -5,35 +5,14 @@ import Foundation
import WidgetKit
public class VoltraModule: Module {
- private let MAX_PAYLOAD_SIZE_IN_BYTES = 4096
- private let WIDGET_JSON_WARNING_SIZE = 50000 // 50KB per widget
- private let TIMELINE_WARNING_SIZE = 100_000 // 100KB per timeline
- private let liveActivityService = VoltraLiveActivityService()
- private var wasLaunchedInBackground: Bool = false
- private var monitoredActivityIds: Set = []
-
- enum VoltraErrors: Error {
+ public enum VoltraErrors: Error {
case unsupportedOS
case notFound
case liveActivitiesNotEnabled
case unexpectedError(Error)
}
- private func validatePayloadSize(_ compressedPayload: String, operation: String) throws {
- let dataSize = compressedPayload.utf8.count
- let safeBudget = 3345 // Keep existing safe budget
- print("Compressed payload size: \(dataSize)B (safe budget \(safeBudget)B, hard cap \(MAX_PAYLOAD_SIZE_IN_BYTES)B)")
-
- if dataSize > safeBudget {
- throw VoltraErrors.unexpectedError(
- NSError(
- domain: "VoltraModule",
- code: operation == "start" ? -10 : -11,
- userInfo: [NSLocalizedDescriptionKey: "Compressed payload too large: \(dataSize)B (safe budget \(safeBudget)B, hard cap \(MAX_PAYLOAD_SIZE_IN_BYTES)B). Reduce the UI before \(operation == "start" ? "starting" : "updating") the Live Activity."]
- )
- )
- }
- }
+ private var impl: VoltraModuleImpl!
public func definition() -> ModuleDefinition {
Name("VoltraModule")
@@ -41,285 +20,101 @@ public class VoltraModule: Module {
// UI component events forwarded from the extension + push/state events
Events("interaction", "activityTokenReceived", "activityPushToStartTokenReceived", "stateChange")
+ OnCreate {
+ self.impl = VoltraModuleImpl()
+ }
+
OnStartObserving {
+ // Subscribe to event bus and forward events to React Native
VoltraEventBus.shared.subscribe { [weak self] eventType, eventData in
self?.sendEvent(eventType, eventData)
}
- if pushNotificationsEnabled {
- observePushToStartToken()
- }
-
- observeLiveActivityUpdates()
+ // Start monitoring live activities and push tokens
+ self.impl.startMonitoring()
}
OnStopObserving {
- VoltraEventBus.shared.unsubscribe()
- monitoredActivityIds.removeAll()
- }
-
- OnCreate {
- // Track if app was launched in background (headless)
- wasLaunchedInBackground = UIApplication.shared.applicationState == .background
-
- // Clean up data for widgets that are no longer installed
- cleanupOrphanedWidgetData()
+ self.impl.stopMonitoring()
}
AsyncFunction("startLiveActivity") { (jsonString: String, options: StartVoltraOptions?) async throws -> String in
- guard #available(iOS 16.2, *) else { throw VoltraErrors.unsupportedOS }
- guard VoltraLiveActivityService.areActivitiesEnabled() else {
- throw VoltraErrors.liveActivitiesNotEnabled
- }
-
- do {
- // Compress JSON using brotli level 2
- let compressedJson = try BrotliCompression.compress(jsonString: jsonString)
- try validatePayloadSize(compressedJson, operation: "start")
-
- let activityName = options?.activityName?.trimmingCharacters(in: .whitespacesAndNewlines)
-
- // Extract staleDate and relevanceScore from options
- let staleDate: Date? = {
- if let staleDateMs = options?.staleDate {
- return Date(timeIntervalSince1970: staleDateMs / 1000.0)
- }
- return nil
- }()
- let relevanceScore: Double = options?.relevanceScore ?? 0.0
-
- // Create request struct with compressed JSON
- let createRequest = CreateActivityRequest(
- activityId: activityName,
- deepLinkUrl: options?.deepLinkUrl,
- jsonString: compressedJson,
- staleDate: staleDate,
- relevanceScore: relevanceScore,
- pushType: pushNotificationsEnabled ? .token : nil,
- endExistingWithSameName: true
- )
-
- // Create activity using service
- let finalActivityId = try await liveActivityService.createActivity(createRequest)
-
- return finalActivityId
- } catch {
- print("Error starting Voltra instance: \(error)")
- // Map service errors to module errors
- if let serviceError = error as? VoltraLiveActivityError {
- switch serviceError {
- case .unsupportedOS:
- throw VoltraErrors.unsupportedOS
- case .liveActivitiesNotEnabled:
- throw VoltraErrors.liveActivitiesNotEnabled
- case .notFound:
- throw VoltraErrors.notFound
- }
- }
- throw VoltraErrors.unexpectedError(error)
- }
+ return try await self.impl.startLiveActivity(jsonString: jsonString, options: options)
}
AsyncFunction("updateLiveActivity") { (activityId: String, jsonString: String, options: UpdateVoltraOptions?) async throws in
- guard #available(iOS 16.2, *) else { throw VoltraErrors.unsupportedOS }
-
- // Compress JSON using brotli level 2
- let compressedJson = try BrotliCompression.compress(jsonString: jsonString)
- try validatePayloadSize(compressedJson, operation: "update")
-
- // Extract staleDate and relevanceScore from options
- let staleDate: Date? = {
- if let staleDateMs = options?.staleDate {
- return Date(timeIntervalSince1970: staleDateMs / 1000.0)
- }
- return nil
- }()
- let relevanceScore: Double = options?.relevanceScore ?? 0.0
-
- // Create update request struct with compressed JSON
- let updateRequest = UpdateActivityRequest(
- jsonString: compressedJson,
- staleDate: staleDate,
- relevanceScore: relevanceScore
- )
-
- do {
- try await liveActivityService.updateActivity(byName: activityId, request: updateRequest)
- } catch {
- if let serviceError = error as? VoltraLiveActivityError {
- switch serviceError {
- case .unsupportedOS:
- throw VoltraErrors.unsupportedOS
- case .notFound:
- throw VoltraErrors.notFound
- case .liveActivitiesNotEnabled:
- throw VoltraErrors.liveActivitiesNotEnabled
- }
- }
- throw VoltraErrors.unexpectedError(error)
- }
+ try await self.impl.updateLiveActivity(activityId: activityId, jsonString: jsonString, options: options)
}
AsyncFunction("endLiveActivity") { (activityId: String, options: EndVoltraOptions?) async throws in
- guard #available(iOS 16.2, *) else { throw VoltraErrors.unsupportedOS }
-
- // Convert dismissal policy options to ActivityKit type
- let dismissalPolicy = convertToActivityKitDismissalPolicy(options?.dismissalPolicy)
-
- do {
- try await liveActivityService.endActivity(byName: activityId, dismissalPolicy: dismissalPolicy)
- } catch {
- if let serviceError = error as? VoltraLiveActivityError {
- switch serviceError {
- case .unsupportedOS:
- throw VoltraErrors.unsupportedOS
- case .notFound:
- throw VoltraErrors.notFound
- case .liveActivitiesNotEnabled:
- throw VoltraErrors.liveActivitiesNotEnabled
- }
- }
- throw VoltraErrors.unexpectedError(error)
- }
+ try await self.impl.endLiveActivity(activityId: activityId, options: options)
}
// Preferred name mirroring iOS terminology
AsyncFunction("endAllLiveActivities") { () async throws in
- guard #available(iOS 16.2, *) else { throw VoltraErrors.unsupportedOS }
- await liveActivityService.endAllActivities()
+ try await self.impl.endAllLiveActivities()
}
// Return the latest (most recently created) Voltra Live Activity ID, if any.
// Useful to rebind after Fast Refresh in development.
AsyncFunction("getLatestVoltraActivityId") { () -> String? in
- guard #available(iOS 16.2, *) else { return nil }
- return liveActivityService.getLatestActivity()?.id
+ return self.impl.getLatestVoltraActivityId()
}
// Debug helper: list all running Voltra Live Activity IDs
AsyncFunction("listVoltraActivityIds") { () -> [String] in
- guard #available(iOS 16.2, *) else { return [] }
- return liveActivityService.getAllActivities().map(\.id)
+ return self.impl.listVoltraActivityIds()
}
Function("isLiveActivityActive") { (activityName: String) -> Bool in
- guard #available(iOS 16.2, *) else { return false }
- return liveActivityService.isActivityActive(name: activityName)
+ return self.impl.isLiveActivityActive(name: activityName)
}
Function("isHeadless") { () -> Bool in
- return wasLaunchedInBackground
+ return self.impl.wasLaunchedInBackground
}
// Preload images to App Group storage for use in Live Activities
AsyncFunction("preloadImages") { (images: [PreloadImageOptions]) async throws -> PreloadImagesResult in
- var succeeded: [String] = []
- var failed: [PreloadImageFailure] = []
-
- for imageOptions in images {
- do {
- try await self.downloadAndSaveImage(imageOptions)
- succeeded.append(imageOptions.key)
- } catch {
- failed.append(PreloadImageFailure(key: imageOptions.key, error: error.localizedDescription))
- }
- }
-
- return PreloadImagesResult(succeeded: succeeded, failed: failed)
+ return try await self.impl.preloadImages(images: images)
}
// Reload Live Activities to pick up preloaded images
// This triggers an update with the same content state, forcing SwiftUI to re-render
AsyncFunction("reloadLiveActivities") { (activityNames: [String]?) async throws in
- guard #available(iOS 16.2, *) else { throw VoltraErrors.unsupportedOS }
-
- let activities = self.liveActivityService.getAllActivities()
-
- for activity in activities {
- // If activityNames is provided, only reload those specific activities
- if let names = activityNames, !names.isEmpty {
- guard names.contains(activity.attributes.name) else { continue }
- }
-
- do {
- let newState = try VoltraAttributes.ContentState(
- uiJsonData: activity.content.state.uiJsonData
- )
-
- await activity.update(
- ActivityContent(
- state: newState,
- staleDate: activity.content.staleDate,
- relevanceScore: activity.content.relevanceScore
- )
- )
- print("[Voltra] Reloaded Live Activity '\(activity.attributes.name)'")
- } catch {
- print("[Voltra] Failed to reload Live Activity '\(activity.attributes.name)': \(error)")
- }
- }
+ try await self.impl.reloadLiveActivities(activityNames: activityNames)
}
// Clear preloaded images from App Group storage
AsyncFunction("clearPreloadedImages") { (keys: [String]?) async in
- if let keys = keys, !keys.isEmpty {
- // Clear specific images
- VoltraImageStore.removeImages(keys: keys)
- print("[Voltra] Cleared preloaded images: \(keys.joined(separator: ", "))")
- } else {
- // Clear all preloaded images
- VoltraImageStore.clearAll()
- print("[Voltra] Cleared all preloaded images")
- }
+ await self.impl.clearPreloadedImages(keys: keys)
}
// MARK: - Home Screen Widget Functions
// Update a home screen widget with new content
AsyncFunction("updateWidget") { (widgetId: String, jsonString: String, options: UpdateWidgetOptions?) async throws in
- try self.writeWidgetData(widgetId: widgetId, jsonString: jsonString, deepLinkUrl: options?.deepLinkUrl)
-
- // Clear any scheduled timeline so single-entry data takes effect
- self.clearWidgetTimeline(widgetId: widgetId)
-
- // Reload the widget timeline
- WidgetCenter.shared.reloadTimelines(ofKind: "Voltra_Widget_\(widgetId)")
- print("[Voltra] Updated widget '\(widgetId)'")
+ try await self.impl.updateWidget(widgetId: widgetId, jsonString: jsonString, options: options)
}
// Schedule a widget timeline with multiple entries
AsyncFunction("scheduleWidget") { (widgetId: String, timelineJson: String) async throws in
- try self.writeWidgetTimeline(widgetId: widgetId, timelineJson: timelineJson)
-
- // Reload the widget timeline to pick up scheduled entries
- WidgetCenter.shared.reloadTimelines(ofKind: "Voltra_Widget_\(widgetId)")
+ try await self.impl.scheduleWidget(widgetId: widgetId, timelineJson: timelineJson)
}
// Reload widget timelines to refresh their content
AsyncFunction("reloadWidgets") { (widgetIds: [String]?) async in
- if let ids = widgetIds, !ids.isEmpty {
- for widgetId in ids {
- WidgetCenter.shared.reloadTimelines(ofKind: "Voltra_Widget_\(widgetId)")
- }
- print("[Voltra] Reloaded widgets: \(ids.joined(separator: ", "))")
- } else {
- WidgetCenter.shared.reloadAllTimelines()
- print("[Voltra] Reloaded all widgets")
- }
+ await self.impl.reloadWidgets(widgetIds: widgetIds)
}
// Clear a widget's stored data
AsyncFunction("clearWidget") { (widgetId: String) async in
- self.clearWidgetData(widgetId: widgetId)
- WidgetCenter.shared.reloadTimelines(ofKind: "Voltra_Widget_\(widgetId)")
- print("[Voltra] Cleared widget '\(widgetId)'")
+ await self.impl.clearWidget(widgetId: widgetId)
}
// Clear all widgets' stored data
AsyncFunction("clearAllWidgets") { () async in
- self.clearAllWidgetData()
- WidgetCenter.shared.reloadAllTimelines()
- print("[Voltra] Cleared all widgets")
+ await self.impl.clearAllWidgets()
}
View(VoltraRN.self) {
@@ -333,312 +128,3 @@ public class VoltraModule: Module {
}
}
}
-
-// Convert dismissal policy options to ActivityKit type
-private extension VoltraModule {
- func convertToActivityKitDismissalPolicy(_ options: DismissalPolicyOptions?) -> ActivityUIDismissalPolicy {
- guard let options = options else {
- return .immediate
- }
-
- switch options.type {
- case "immediate":
- return .immediate
- case "after":
- if let timestamp = options.date {
- let date = Date(timeIntervalSince1970: timestamp / 1000.0)
- return .after(date)
- }
- return .immediate
- default:
- return .immediate
- }
- }
-}
-
-// MARK: - Image Preloading
-
-private extension VoltraModule {
- /// Download and save an image to App Group storage
- func downloadAndSaveImage(_ options: PreloadImageOptions) async throws {
- guard let url = URL(string: options.url) else {
- throw PreloadError.invalidURL(options.url)
- }
-
- // Create request with optional method and headers
- var request = URLRequest(url: url)
- request.httpMethod = options.method ?? "GET"
-
- if let headers = options.headers {
- for (key, value) in headers {
- request.setValue(value, forHTTPHeaderField: key)
- }
- }
-
- // Perform the request
- let (data, response) = try await URLSession.shared.data(for: request)
-
- guard let httpResponse = response as? HTTPURLResponse else {
- throw PreloadError.invalidResponse
- }
-
- guard (200 ... 299).contains(httpResponse.statusCode) else {
- throw PreloadError.httpError(statusCode: httpResponse.statusCode)
- }
-
- // Check Content-Length header first if available
- if let contentLengthString = httpResponse.value(forHTTPHeaderField: "Content-Length"),
- let contentLength = Int(contentLengthString)
- {
- if contentLength >= MAX_PAYLOAD_SIZE_IN_BYTES {
- throw PreloadError.imageTooLarge(key: options.key, size: contentLength)
- }
- }
-
- // Also validate actual data size (in case Content-Length was missing or inaccurate)
- if data.count >= MAX_PAYLOAD_SIZE_IN_BYTES {
- throw PreloadError.imageTooLarge(key: options.key, size: data.count)
- }
-
- // Validate that the data is actually an image
- guard UIImage(data: data) != nil else {
- throw PreloadError.invalidImageData(key: options.key)
- }
-
- // Save to App Group storage
- try VoltraImageStore.saveImage(data, key: options.key)
-
- print("[Voltra] Preloaded image '\(options.key)' (\(data.count) bytes)")
- }
-}
-
-/// Errors that can occur during image preloading
-enum PreloadError: Error, LocalizedError {
- case invalidURL(String)
- case invalidResponse
- case httpError(statusCode: Int)
- case imageTooLarge(key: String, size: Int)
- case invalidImageData(key: String)
- case appGroupNotConfigured
-
- var errorDescription: String? {
- switch self {
- case let .invalidURL(url):
- return "Invalid URL: \(url)"
- case .invalidResponse:
- return "Invalid response from server"
- case let .httpError(statusCode):
- return "HTTP error: \(statusCode)"
- case let .imageTooLarge(key, size):
- return "Image '\(key)' is too large: \(size) bytes (max 4096 bytes for Live Activities)"
- case let .invalidImageData(key):
- return "Invalid image data for '\(key)'"
- case .appGroupNotConfigured:
- return "App Group not configured. Set 'groupIdentifier' in the Voltra config plugin."
- }
- }
-}
-
-// MARK: - Widget Data Management
-
-private extension VoltraModule {
- func writeWidgetData(widgetId: String, jsonString: String, deepLinkUrl: String?) throws {
- guard let groupId = VoltraConfig.groupIdentifier() else {
- throw WidgetError.appGroupNotConfigured
- }
- guard let defaults = UserDefaults(suiteName: groupId) else {
- throw WidgetError.userDefaultsUnavailable
- }
-
- // Check payload size and log warning if too large
- let dataSize = jsonString.utf8.count
- if dataSize > WIDGET_JSON_WARNING_SIZE {
- print("[Voltra] ⚠️ Large widget payload for '\(widgetId)': \(dataSize) bytes (warning threshold: \(WIDGET_JSON_WARNING_SIZE) bytes)")
- }
-
- // Store the JSON payload
- defaults.set(jsonString, forKey: "Voltra_Widget_JSON_\(widgetId)")
-
- // Store or remove deep link URL
- if let url = deepLinkUrl, !url.isEmpty {
- defaults.set(url, forKey: "Voltra_Widget_DeepLinkURL_\(widgetId)")
- } else {
- defaults.removeObject(forKey: "Voltra_Widget_DeepLinkURL_\(widgetId)")
- }
-
- defaults.synchronize()
- }
-
- func writeWidgetTimeline(widgetId: String, timelineJson: String) throws {
- guard let groupId = VoltraConfig.groupIdentifier() else {
- throw WidgetError.appGroupNotConfigured
- }
- guard let defaults = UserDefaults(suiteName: groupId) else {
- throw WidgetError.userDefaultsUnavailable
- }
-
- // Check timeline size and log warning if too large
- let dataSize = timelineJson.utf8.count
- if dataSize > TIMELINE_WARNING_SIZE {
- print("[Voltra] ⚠️ Large timeline for '\(widgetId)': \(dataSize) bytes (warning threshold: \(TIMELINE_WARNING_SIZE) bytes)")
- }
-
- // Store the timeline JSON
- defaults.set(timelineJson, forKey: "Voltra_Widget_Timeline_\(widgetId)")
- defaults.synchronize()
- print("[Voltra] writeWidgetTimeline: Timeline stored successfully")
- }
-
- func clearWidgetData(widgetId: String) {
- guard let groupId = VoltraConfig.groupIdentifier(),
- let defaults = UserDefaults(suiteName: groupId) else { return }
-
- defaults.removeObject(forKey: "Voltra_Widget_JSON_\(widgetId)")
- defaults.removeObject(forKey: "Voltra_Widget_DeepLinkURL_\(widgetId)")
- defaults.removeObject(forKey: "Voltra_Widget_Timeline_\(widgetId)")
- defaults.synchronize()
- }
-
- func clearAllWidgetData() {
- guard let groupId = VoltraConfig.groupIdentifier(),
- let defaults = UserDefaults(suiteName: groupId) else { return }
-
- // Get all widget IDs from Info.plist
- let widgetIds = Bundle.main.object(forInfoDictionaryKey: "Voltra_WidgetIds") as? [String] ?? []
-
- for widgetId in widgetIds {
- defaults.removeObject(forKey: "Voltra_Widget_JSON_\(widgetId)")
- defaults.removeObject(forKey: "Voltra_Widget_DeepLinkURL_\(widgetId)")
- defaults.removeObject(forKey: "Voltra_Widget_Timeline_\(widgetId)")
- }
- defaults.synchronize()
- }
-
- func clearWidgetTimeline(widgetId: String) {
- guard let groupId = VoltraConfig.groupIdentifier(),
- let defaults = UserDefaults(suiteName: groupId) else { return }
-
- defaults.removeObject(forKey: "Voltra_Widget_Timeline_\(widgetId)")
- defaults.synchronize()
- }
-
- func cleanupOrphanedWidgetData() {
- guard let groupId = VoltraConfig.groupIdentifier(),
- let defaults = UserDefaults(suiteName: groupId) else { return }
-
- let knownWidgetIds = Bundle.main.object(forInfoDictionaryKey: "Voltra_WidgetIds") as? [String] ?? []
- guard !knownWidgetIds.isEmpty else { return }
-
- WidgetCenter.shared.getCurrentConfigurations { result in
- guard case let .success(configs) = result else { return }
-
- let installedIds = Set(configs.compactMap { config -> String? in
- let prefix = "Voltra_Widget_"
- guard config.kind.hasPrefix(prefix) else { return nil }
- return String(config.kind.dropFirst(prefix.count))
- })
-
- for widgetId in knownWidgetIds where !installedIds.contains(widgetId) {
- defaults.removeObject(forKey: "Voltra_Widget_JSON_\(widgetId)")
- defaults.removeObject(forKey: "Voltra_Widget_DeepLinkURL_\(widgetId)")
- defaults.removeObject(forKey: "Voltra_Widget_Timeline_\(widgetId)")
- print("[Voltra] Cleaned up orphaned widget data for '\(widgetId)'")
- }
- }
- }
-}
-
-/// Errors that can occur during widget operations
-enum WidgetError: Error, LocalizedError {
- case appGroupNotConfigured
- case userDefaultsUnavailable
-
- var errorDescription: String? {
- switch self {
- case .appGroupNotConfigured:
- return "App Group not configured. Set 'groupIdentifier' in the Voltra config plugin to use widgets."
- case .userDefaultsUnavailable:
- return "Unable to access UserDefaults for the app group."
- }
- }
-}
-
-// MARK: - Push Tokens and Activity State Streams
-
-private extension VoltraModule {
- var pushNotificationsEnabled: Bool {
- // Support both keys for compatibility with older plugin and new Voltra naming
- let main = Bundle.main
- return main.object(forInfoDictionaryKey: "Voltra_EnablePushNotifications") as? Bool ?? false
- }
-
- func observePushToStartToken() {
- guard #available(iOS 17.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else { return }
-
- // Check for initial token if available
- if let initialTokenData = Activity.pushToStartToken {
- let token = initialTokenData.hexString
- VoltraEventBus.shared.send(.pushToStartTokenReceived(token: token))
- }
-
- // Observe token updates
- Task {
- for await tokenData in Activity.pushToStartTokenUpdates {
- let token = tokenData.hexString
- VoltraEventBus.shared.send(.pushToStartTokenReceived(token: token))
- }
- }
- }
-
- func observeLiveActivityUpdates() {
- guard #available(iOS 16.2, *) else { return }
-
- // 1. Handle currently existing activities (e.g., after app restart)
- for activity in Activity.activities {
- monitorActivity(activity)
- }
-
- // 2. Listen for NEW activities created in the future (e.g., push-to-start)
- Task {
- for await newActivity in Activity.activityUpdates {
- monitorActivity(newActivity)
- }
- }
- }
-
- /// Set up observers for an activity's lifecycle (only once per activity)
- private func monitorActivity(_ activity: Activity) {
- let activityId = activity.id
-
- // Skip if we're already monitoring this activity
- guard !monitoredActivityIds.contains(activityId) else { return }
- monitoredActivityIds.insert(activityId)
-
- // Observe lifecycle state changes (active → dismissed → ended)
- Task {
- for await state in activity.activityStateUpdates {
- VoltraEventBus.shared.send(
- .stateChange(
- activityName: activity.attributes.name,
- state: String(describing: state)
- )
- )
- }
- }
-
- // Observe push token updates if enabled
- if pushNotificationsEnabled {
- Task {
- for await pushTokenData in activity.pushTokenUpdates {
- let pushTokenString = pushTokenData.hexString
- VoltraEventBus.shared.send(
- .tokenReceived(
- activityName: activity.attributes.name,
- pushToken: pushTokenString
- )
- )
- }
- }
- }
- }
-}
diff --git a/ios/app/VoltraModuleImpl.swift b/ios/app/VoltraModuleImpl.swift
new file mode 100644
index 0000000..3a55331
--- /dev/null
+++ b/ios/app/VoltraModuleImpl.swift
@@ -0,0 +1,487 @@
+import ActivityKit
+import Compression
+import ExpoModulesCore
+import Foundation
+import WidgetKit
+
+/// Implementation details for VoltraModule to keep the main module file clean
+public class VoltraModuleImpl {
+ private let MAX_PAYLOAD_SIZE_IN_BYTES = 4096
+ private let WIDGET_JSON_WARNING_SIZE = 50000 // 50KB per widget
+ private let TIMELINE_WARNING_SIZE = 100_000 // 100KB per timeline
+ private let liveActivityService = VoltraLiveActivityService()
+
+ public var wasLaunchedInBackground: Bool = false
+
+ public init() {
+ // Track if app was launched in background (headless)
+ wasLaunchedInBackground = UIApplication.shared.applicationState == .background
+
+ // Clean up data for widgets that are no longer installed
+ cleanupOrphanedWidgetData()
+ }
+
+ var pushNotificationsEnabled: Bool {
+ // Support both keys for compatibility with older plugin and new Voltra naming
+ let main = Bundle.main
+ return main.object(forInfoDictionaryKey: "Voltra_EnablePushNotifications") as? Bool ?? false
+ }
+
+ // MARK: - Lifecycle & Monitoring
+
+ func startMonitoring() {
+ // Note: Event bus subscription is handled by VoltraModule since it has access to sendEvent()
+ liveActivityService.startMonitoring(enablePush: pushNotificationsEnabled)
+ }
+
+ func stopMonitoring() {
+ VoltraEventBus.shared.unsubscribe()
+ liveActivityService.stopMonitoring()
+ }
+
+ // MARK: - Live Activities
+
+ func startLiveActivity(jsonString: String, options: StartVoltraOptions?) async throws -> String {
+ guard #available(iOS 16.2, *) else { throw VoltraModule.VoltraErrors.unsupportedOS }
+ guard VoltraLiveActivityService.areActivitiesEnabled() else {
+ throw VoltraModule.VoltraErrors.liveActivitiesNotEnabled
+ }
+
+ do {
+ // Compress JSON using brotli level 2
+ let compressedJson = try BrotliCompression.compress(jsonString: jsonString)
+ try validatePayloadSize(compressedJson, operation: "start")
+
+ let activityName = options?.activityName?.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Extract staleDate and relevanceScore from options
+ let staleDate: Date? = {
+ if let staleDateMs = options?.staleDate {
+ return Date(timeIntervalSince1970: staleDateMs / 1000.0)
+ }
+ return nil
+ }()
+ let relevanceScore: Double = options?.relevanceScore ?? 0.0
+
+ // Create request struct with compressed JSON
+ let createRequest = CreateActivityRequest(
+ activityId: activityName,
+ deepLinkUrl: options?.deepLinkUrl,
+ jsonString: compressedJson,
+ staleDate: staleDate,
+ relevanceScore: relevanceScore,
+ pushType: pushNotificationsEnabled ? .token : nil,
+ endExistingWithSameName: true
+ )
+
+ // Create activity using service
+ return try await liveActivityService.createActivity(createRequest)
+ } catch {
+ print("Error starting Voltra instance: \(error)")
+ throw mapError(error)
+ }
+ }
+
+ func updateLiveActivity(activityId: String, jsonString: String, options: UpdateVoltraOptions?) async throws {
+ guard #available(iOS 16.2, *) else { throw VoltraModule.VoltraErrors.unsupportedOS }
+
+ // Compress JSON using brotli level 2
+ let compressedJson = try BrotliCompression.compress(jsonString: jsonString)
+ try validatePayloadSize(compressedJson, operation: "update")
+
+ // Extract staleDate and relevanceScore from options
+ let staleDate: Date? = {
+ if let staleDateMs = options?.staleDate {
+ return Date(timeIntervalSince1970: staleDateMs / 1000.0)
+ }
+ return nil
+ }()
+ let relevanceScore: Double = options?.relevanceScore ?? 0.0
+
+ // Create update request struct with compressed JSON
+ let updateRequest = UpdateActivityRequest(
+ jsonString: compressedJson,
+ staleDate: staleDate,
+ relevanceScore: relevanceScore
+ )
+
+ do {
+ try await liveActivityService.updateActivity(byName: activityId, request: updateRequest)
+ } catch {
+ throw mapError(error)
+ }
+ }
+
+ func endLiveActivity(activityId: String, options: EndVoltraOptions?) async throws {
+ guard #available(iOS 16.2, *) else { throw VoltraModule.VoltraErrors.unsupportedOS }
+
+ // Convert dismissal policy options to ActivityKit type
+ let dismissalPolicy = convertToActivityKitDismissalPolicy(options?.dismissalPolicy)
+
+ do {
+ try await liveActivityService.endActivity(byName: activityId, dismissalPolicy: dismissalPolicy)
+ } catch {
+ throw mapError(error)
+ }
+ }
+
+ func endAllLiveActivities() async throws {
+ guard #available(iOS 16.2, *) else { throw VoltraModule.VoltraErrors.unsupportedOS }
+ await liveActivityService.endAllActivities()
+ }
+
+ func getLatestVoltraActivityId() -> String? {
+ guard #available(iOS 16.2, *) else { return nil }
+ return liveActivityService.getLatestActivity()?.id
+ }
+
+ func listVoltraActivityIds() -> [String] {
+ guard #available(iOS 16.2, *) else { return [] }
+ return liveActivityService.getAllActivities().map(\.id)
+ }
+
+ func isLiveActivityActive(name: String) -> Bool {
+ guard #available(iOS 16.2, *) else { return false }
+ return liveActivityService.isActivityActive(name: name)
+ }
+
+ func reloadLiveActivities(activityNames: [String]?) async throws {
+ guard #available(iOS 16.2, *) else { throw VoltraModule.VoltraErrors.unsupportedOS }
+
+ let activities = liveActivityService.getAllActivities()
+
+ for activity in activities {
+ // If activityNames is provided, only reload those specific activities
+ if let names = activityNames, !names.isEmpty {
+ guard names.contains(activity.attributes.name) else { continue }
+ }
+
+ do {
+ let newState = try VoltraAttributes.ContentState(
+ uiJsonData: activity.content.state.uiJsonData
+ )
+
+ await activity.update(
+ ActivityContent(
+ state: newState,
+ staleDate: nil,
+ relevanceScore: 0.0
+ )
+ )
+ print("[Voltra] Reloaded Live Activity '\(activity.attributes.name)'")
+ } catch {
+ print("[Voltra] Failed to reload Live Activity '\(activity.attributes.name)': \(error)")
+ }
+ }
+ }
+
+ // MARK: - Image Preloading
+
+ func preloadImages(images: [PreloadImageOptions]) async throws -> PreloadImagesResult {
+ var succeeded: [String] = []
+ var failed: [PreloadImageFailure] = []
+
+ for imageOptions in images {
+ do {
+ try await downloadAndSaveImage(imageOptions)
+ succeeded.append(imageOptions.key)
+ } catch {
+ failed.append(PreloadImageFailure(key: imageOptions.key, error: error.localizedDescription))
+ }
+ }
+
+ return PreloadImagesResult(succeeded: succeeded, failed: failed)
+ }
+
+ func clearPreloadedImages(keys: [String]?) async {
+ if let keys = keys, !keys.isEmpty {
+ // Clear specific images
+ VoltraImageStore.removeImages(keys: keys)
+ print("[Voltra] Cleared preloaded images: \(keys.joined(separator: ", "))")
+ } else {
+ // Clear all preloaded images
+ VoltraImageStore.clearAll()
+ print("[Voltra] Cleared all preloaded images")
+ }
+ }
+
+ // MARK: - Widgets
+
+ func updateWidget(widgetId: String, jsonString: String, options: UpdateWidgetOptions?) async throws {
+ try writeWidgetData(widgetId: widgetId, jsonString: jsonString, deepLinkUrl: options?.deepLinkUrl)
+
+ // Clear any scheduled timeline so single-entry data takes effect
+ clearWidgetTimeline(widgetId: widgetId)
+
+ // Reload the widget timeline
+ WidgetCenter.shared.reloadTimelines(ofKind: "Voltra_Widget_\(widgetId)")
+ print("[Voltra] Updated widget '\(widgetId)'")
+ }
+
+ func scheduleWidget(widgetId: String, timelineJson: String) async throws {
+ try writeWidgetTimeline(widgetId: widgetId, timelineJson: timelineJson)
+
+ // Reload the widget timeline to pick up scheduled entries
+ WidgetCenter.shared.reloadTimelines(ofKind: "Voltra_Widget_\(widgetId)")
+ }
+
+ func reloadWidgets(widgetIds: [String]?) async {
+ if let ids = widgetIds, !ids.isEmpty {
+ for widgetId in ids {
+ WidgetCenter.shared.reloadTimelines(ofKind: "Voltra_Widget_\(widgetId)")
+ }
+ print("[Voltra] Reloaded widgets: \(ids.joined(separator: ", "))")
+ } else {
+ WidgetCenter.shared.reloadAllTimelines()
+ print("[Voltra] Reloaded all widgets")
+ }
+ }
+
+ func clearWidget(widgetId: String) async {
+ clearWidgetData(widgetId: widgetId)
+ WidgetCenter.shared.reloadTimelines(ofKind: "Voltra_Widget_\(widgetId)")
+ print("[Voltra] Cleared widget '\(widgetId)'")
+ }
+
+ func clearAllWidgets() async {
+ clearAllWidgetData()
+ WidgetCenter.shared.reloadAllTimelines()
+ print("[Voltra] Cleared all widgets")
+ }
+
+ // MARK: - Private Helpers
+
+ private func mapError(_ error: Error) -> Error {
+ if let serviceError = error as? VoltraLiveActivityError {
+ switch serviceError {
+ case .unsupportedOS:
+ return VoltraModule.VoltraErrors.unsupportedOS
+ case .liveActivitiesNotEnabled:
+ return VoltraModule.VoltraErrors.liveActivitiesNotEnabled
+ case .notFound:
+ return VoltraModule.VoltraErrors.notFound
+ }
+ }
+ return VoltraModule.VoltraErrors.unexpectedError(error)
+ }
+
+ private func validatePayloadSize(_ compressedPayload: String, operation: String) throws {
+ let dataSize = compressedPayload.utf8.count
+ let safeBudget = 3345 // Keep existing safe budget
+ print("Compressed payload size: \(dataSize)B (safe budget \(safeBudget)B, hard cap \(MAX_PAYLOAD_SIZE_IN_BYTES)B)")
+
+ if dataSize > safeBudget {
+ throw VoltraModule.VoltraErrors.unexpectedError(
+ NSError(
+ domain: "VoltraModule",
+ code: operation == "start" ? -10 : -11,
+ userInfo: [NSLocalizedDescriptionKey: "Compressed payload too large: \(dataSize)B (safe budget \(safeBudget)B, hard cap \(MAX_PAYLOAD_SIZE_IN_BYTES)B). Reduce the UI before \(operation == "start" ? "starting" : "updating") the Live Activity."]
+ )
+ )
+ }
+ }
+
+ private func convertToActivityKitDismissalPolicy(_ options: DismissalPolicyOptions?) -> ActivityUIDismissalPolicy {
+ guard let options = options else {
+ return .immediate
+ }
+
+ switch options.type {
+ case "immediate":
+ return .immediate
+ case "after":
+ if let timestamp = options.date {
+ let date = Date(timeIntervalSince1970: timestamp / 1000.0)
+ return .after(date)
+ }
+ return .immediate
+ default:
+ return .immediate
+ }
+ }
+
+ private func downloadAndSaveImage(_ options: PreloadImageOptions) async throws {
+ guard let url = URL(string: options.url) else {
+ throw PreloadError.invalidURL(options.url)
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = options.method ?? "GET"
+
+ if let headers = options.headers {
+ for (key, value) in headers {
+ request.setValue(value, forHTTPHeaderField: key)
+ }
+ }
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw PreloadError.invalidResponse
+ }
+
+ guard (200 ... 299).contains(httpResponse.statusCode) else {
+ throw PreloadError.httpError(statusCode: httpResponse.statusCode)
+ }
+
+ if let contentLengthString = httpResponse.value(forHTTPHeaderField: "Content-Length"),
+ let contentLength = Int(contentLengthString)
+ {
+ if contentLength >= MAX_PAYLOAD_SIZE_IN_BYTES {
+ throw PreloadError.imageTooLarge(key: options.key, size: contentLength)
+ }
+ }
+
+ if data.count >= MAX_PAYLOAD_SIZE_IN_BYTES {
+ throw PreloadError.imageTooLarge(key: options.key, size: data.count)
+ }
+
+ guard UIImage(data: data) != nil else {
+ throw PreloadError.invalidImageData(key: options.key)
+ }
+
+ try VoltraImageStore.saveImage(data, key: options.key)
+ print("[Voltra] Preloaded image '\(options.key)' (\(data.count) bytes)")
+ }
+
+ private func writeWidgetData(widgetId: String, jsonString: String, deepLinkUrl: String?) throws {
+ guard let groupId = VoltraConfig.groupIdentifier() else {
+ throw WidgetError.appGroupNotConfigured
+ }
+ guard let defaults = UserDefaults(suiteName: groupId) else {
+ throw WidgetError.userDefaultsUnavailable
+ }
+
+ let dataSize = jsonString.utf8.count
+ if dataSize > WIDGET_JSON_WARNING_SIZE {
+ print("[Voltra] ⚠️ Large widget payload for '\(widgetId)': \(dataSize) bytes (warning threshold: \(WIDGET_JSON_WARNING_SIZE) bytes)")
+ }
+
+ defaults.set(jsonString, forKey: "Voltra_Widget_JSON_\(widgetId)")
+
+ if let url = deepLinkUrl, !url.isEmpty {
+ defaults.set(url, forKey: "Voltra_Widget_DeepLinkURL_\(widgetId)")
+ } else {
+ defaults.removeObject(forKey: "Voltra_Widget_DeepLinkURL_\(widgetId)")
+ }
+
+ defaults.synchronize()
+ }
+
+ private func writeWidgetTimeline(widgetId: String, timelineJson: String) throws {
+ guard let groupId = VoltraConfig.groupIdentifier() else {
+ throw WidgetError.appGroupNotConfigured
+ }
+ guard let defaults = UserDefaults(suiteName: groupId) else {
+ throw WidgetError.userDefaultsUnavailable
+ }
+
+ let dataSize = timelineJson.utf8.count
+ if dataSize > TIMELINE_WARNING_SIZE {
+ print("[Voltra] ⚠️ Large timeline for '\(widgetId)': \(dataSize) bytes (warning threshold: \(TIMELINE_WARNING_SIZE) bytes)")
+ }
+
+ defaults.set(timelineJson, forKey: "Voltra_Widget_Timeline_\(widgetId)")
+ defaults.synchronize()
+ print("[Voltra] writeWidgetTimeline: Timeline stored successfully")
+ }
+
+ private func clearWidgetData(widgetId: String) {
+ guard let groupId = VoltraConfig.groupIdentifier(),
+ let defaults = UserDefaults(suiteName: groupId) else { return }
+
+ defaults.removeObject(forKey: "Voltra_Widget_JSON_\(widgetId)")
+ defaults.removeObject(forKey: "Voltra_Widget_DeepLinkURL_\(widgetId)")
+ defaults.removeObject(forKey: "Voltra_Widget_Timeline_\(widgetId)")
+ defaults.synchronize()
+ }
+
+ private func clearAllWidgetData() {
+ guard let groupId = VoltraConfig.groupIdentifier(),
+ let defaults = UserDefaults(suiteName: groupId) else { return }
+
+ let widgetIds = Bundle.main.object(forInfoDictionaryKey: "Voltra_WidgetIds") as? [String] ?? []
+
+ for widgetId in widgetIds {
+ defaults.removeObject(forKey: "Voltra_Widget_JSON_\(widgetId)")
+ defaults.removeObject(forKey: "Voltra_Widget_DeepLinkURL_\(widgetId)")
+ defaults.removeObject(forKey: "Voltra_Widget_Timeline_\(widgetId)")
+ }
+ defaults.synchronize()
+ }
+
+ private func clearWidgetTimeline(widgetId: String) {
+ guard let groupId = VoltraConfig.groupIdentifier(),
+ let defaults = UserDefaults(suiteName: groupId) else { return }
+
+ defaults.removeObject(forKey: "Voltra_Widget_Timeline_\(widgetId)")
+ defaults.synchronize()
+ }
+
+ private func cleanupOrphanedWidgetData() {
+ guard let groupId = VoltraConfig.groupIdentifier(),
+ let defaults = UserDefaults(suiteName: groupId) else { return }
+
+ let knownWidgetIds = Bundle.main.object(forInfoDictionaryKey: "Voltra_WidgetIds") as? [String] ?? []
+ guard !knownWidgetIds.isEmpty else { return }
+
+ WidgetCenter.shared.getCurrentConfigurations { result in
+ guard case let .success(configs) = result else { return }
+
+ let installedIds = Set(configs.compactMap { config -> String? in
+ let prefix = "Voltra_Widget_"
+ guard config.kind.hasPrefix(prefix) else { return nil }
+ return String(config.kind.dropFirst(prefix.count))
+ })
+
+ for widgetId in knownWidgetIds where !installedIds.contains(widgetId) {
+ defaults.removeObject(forKey: "Voltra_Widget_JSON_\(widgetId)")
+ defaults.removeObject(forKey: "Voltra_Widget_DeepLinkURL_\(widgetId)")
+ defaults.removeObject(forKey: "Voltra_Widget_Timeline_\(widgetId)")
+ print("[Voltra] Cleaned up orphaned widget data for '\(widgetId)'")
+ }
+ }
+ }
+}
+
+/// Errors that can occur during image preloading
+enum PreloadError: Error, LocalizedError {
+ case invalidURL(String)
+ case invalidResponse
+ case httpError(statusCode: Int)
+ case imageTooLarge(key: String, size: Int)
+ case invalidImageData(key: String)
+ case appGroupNotConfigured
+
+ var errorDescription: String? {
+ switch self {
+ case let .invalidURL(url):
+ return "Invalid URL: \(url)"
+ case .invalidResponse:
+ return "Invalid response from server"
+ case let .httpError(statusCode):
+ return "HTTP error: \(statusCode)"
+ case let .imageTooLarge(key, size):
+ return "Image '\(key)' is too large: \(size) bytes (max 4096 bytes for Live Activities)"
+ case let .invalidImageData(key):
+ return "Invalid image data for '\(key)'"
+ case .appGroupNotConfigured:
+ return "App Group not configured. Set 'groupIdentifier' in the Voltra config plugin."
+ }
+ }
+}
+
+/// Errors that can occur during widget operations
+enum WidgetError: Error, LocalizedError {
+ case appGroupNotConfigured
+ case userDefaultsUnavailable
+
+ var errorDescription: String? {
+ switch self {
+ case .appGroupNotConfigured:
+ return "App Group not configured. Set 'groupIdentifier' in the Voltra config plugin to use widgets."
+ case .userDefaultsUnavailable:
+ return "Unable to access UserDefaults for the app group."
+ }
+ }
+}
diff --git a/ios/shared/VoltraRegion.swift b/ios/shared/VoltraRegion.swift
index f27d914..d799741 100644
--- a/ios/shared/VoltraRegion.swift
+++ b/ios/shared/VoltraRegion.swift
@@ -9,6 +9,7 @@ public enum VoltraRegion: String, Codable, Hashable, CaseIterable {
case islandCompactLeading
case islandCompactTrailing
case islandMinimal
+ case supplementalActivityFamiliesSmall
/// The JSON key for this region in the payload
public var jsonKey: String {
@@ -29,6 +30,8 @@ public enum VoltraRegion: String, Codable, Hashable, CaseIterable {
return "isl_cmp_t"
case .islandMinimal:
return "isl_min"
+ case .supplementalActivityFamiliesSmall:
+ return "saf_sm"
}
}
}
diff --git a/ios/target/VoltraWidget.swift b/ios/target/VoltraWidget.swift
index dda283f..dcec1b8 100644
--- a/ios/target/VoltraWidget.swift
+++ b/ios/target/VoltraWidget.swift
@@ -14,50 +14,145 @@ public struct VoltraWidget: Widget {
}
public var body: some WidgetConfiguration {
+ if #available(iOS 18.0, *) {
+ return adaptiveConfig()
+ } else {
+ return defaultConfig()
+ }
+ }
+
+ // MARK: - iOS 18+ Configuration (with supplemental activity families)
+
+ @available(iOS 18.0, *)
+ private func adaptiveConfig() -> some WidgetConfiguration {
ActivityConfiguration(for: VoltraAttributes.self) { context in
- Voltra(root: rootNode(for: .lockScreen, from: context.state), activityId: context.activityID)
+ adaptiveLocksScreenView(context: context)
.widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
.voltraIfLet(context.state.activityBackgroundTint) { view, tint in
let color = JSColorParser.parse(tint)
view.activityBackgroundTint(color)
}
} dynamicIsland: { context in
- let dynamicIsland = DynamicIsland {
- DynamicIslandExpandedRegion(.leading) {
- Voltra(root: rootNode(for: .islandExpandedLeading, from: context.state), activityId: context.activityID)
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- }
- DynamicIslandExpandedRegion(.trailing) {
- Voltra(root: rootNode(for: .islandExpandedTrailing, from: context.state), activityId: context.activityID)
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- }
- DynamicIslandExpandedRegion(.center) {
- Voltra(root: rootNode(for: .islandExpandedCenter, from: context.state), activityId: context.activityID)
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- }
- DynamicIslandExpandedRegion(.bottom) {
- Voltra(root: rootNode(for: .islandExpandedBottom, from: context.state), activityId: context.activityID)
- .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
+ dynamicIslandContent(context: context)
+ }
+ .supplementalActivityFamilies([.small, .medium])
+ }
+
+ // MARK: - Default Configuration (iOS 16.2 - 17.x)
+
+ private func defaultConfig() -> some WidgetConfiguration {
+ ActivityConfiguration(for: VoltraAttributes.self) { context in
+ Voltra(root: rootNode(for: .lockScreen, from: context.state), activityId: context.activityID)
+ .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
+ .voltraIfLet(context.state.activityBackgroundTint) { view, tint in
+ let color = JSColorParser.parse(tint)
+ view.activityBackgroundTint(color)
}
- } compactLeading: {
- Voltra(root: rootNode(for: .islandCompactLeading, from: context.state), activityId: context.activityID)
+ } dynamicIsland: { context in
+ dynamicIslandContent(context: context)
+ }
+ }
+
+ // MARK: - Adaptive Lock Screen View (iOS 18+)
+
+ @available(iOS 18.0, *)
+ private func adaptiveLocksScreenView(context: ActivityViewContext) -> some View {
+ VoltraAdaptiveLockScreenView(context: context, rootNodeProvider: rootNode)
+ }
+
+ // MARK: - Dynamic Island (shared between iOS versions)
+
+ private func dynamicIslandContent(context: ActivityViewContext) -> DynamicIsland {
+ let dynamicIsland = DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ Voltra(root: rootNode(for: .islandExpandedLeading, from: context.state), activityId: context.activityID)
.widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- } compactTrailing: {
- Voltra(root: rootNode(for: .islandCompactTrailing, from: context.state), activityId: context.activityID)
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ Voltra(root: rootNode(for: .islandExpandedTrailing, from: context.state), activityId: context.activityID)
+ .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
+ }
+ DynamicIslandExpandedRegion(.center) {
+ Voltra(root: rootNode(for: .islandExpandedCenter, from: context.state), activityId: context.activityID)
.widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
- } minimal: {
- Voltra(root: rootNode(for: .islandMinimal, from: context.state), activityId: context.activityID)
+ }
+ DynamicIslandExpandedRegion(.bottom) {
+ Voltra(root: rootNode(for: .islandExpandedBottom, from: context.state), activityId: context.activityID)
.widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
}
+ } compactLeading: {
+ Voltra(root: rootNode(for: .islandCompactLeading, from: context.state), activityId: context.activityID)
+ .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
+ } compactTrailing: {
+ Voltra(root: rootNode(for: .islandCompactTrailing, from: context.state), activityId: context.activityID)
+ .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
+ } minimal: {
+ Voltra(root: rootNode(for: .islandMinimal, from: context.state), activityId: context.activityID)
+ .widgetURL(VoltraDeepLinkResolver.resolve(context.attributes))
+ }
+
+ // Apply keylineTint if specified
+ if let keylineTint = context.state.keylineTint,
+ let color = JSColorParser.parse(keylineTint)
+ {
+ return dynamicIsland.keylineTint(color)
+ } else {
+ return dynamicIsland
+ }
+ }
+}
+
+// MARK: - Adaptive Lock Screen View Helper
- // Apply keylineTint if specified
- if let keylineTint = context.state.keylineTint,
- let color = JSColorParser.parse(keylineTint)
- {
- return dynamicIsland.keylineTint(color)
- } else {
- return dynamicIsland
+@available(iOS 18.0, *)
+private struct VoltraAdaptiveLockScreenView: View {
+ let context: ActivityViewContext
+ let rootNodeProvider: (VoltraRegion, VoltraAttributes.ContentState) -> VoltraNode
+
+ @Environment(\.activityFamily) private var activityFamily
+
+ var body: some View {
+ switch activityFamily {
+ case .small:
+ watchContent()
+ case .medium:
+ defaultContent()
+ @unknown default:
+ defaultContent()
+ }
+ }
+
+ @ViewBuilder
+ private func watchContent() -> some View {
+ let leading = context.state.regions[.islandCompactLeading] ?? []
+ let trailing = context.state.regions[.islandCompactTrailing] ?? []
+ let hasCompactContent = !leading.isEmpty || !trailing.isEmpty
+
+ // 1. Try dedicated Watch region
+ if let nodes = context.state.regions[.supplementalActivityFamiliesSmall], !nodes.isEmpty {
+ Voltra(root: rootNodeProvider(.supplementalActivityFamiliesSmall, context.state), activityId: context.activityID)
+ }
+ // 2. Compose from compact Dynamic Island regions
+ else if hasCompactContent {
+ HStack(spacing: 0) {
+ if !leading.isEmpty {
+ Voltra(root: rootNodeProvider(.islandCompactLeading, context.state), activityId: context.activityID)
+ }
+ Spacer()
+ if !trailing.isEmpty {
+ Voltra(root: rootNodeProvider(.islandCompactTrailing, context.state), activityId: context.activityID)
+ }
}
+ .frame(maxWidth: .infinity)
}
+ // 3. No content available for Watch
+ else {
+ EmptyView()
+ }
+ }
+
+ private func defaultContent() -> some View {
+ // Default content for both StandBy (.medium) and unknown activity families
+ Voltra(root: rootNodeProvider(.lockScreen, context.state), activityId: context.activityID)
}
}
diff --git a/plugin/src/features/ios/files/swift/widgetBundle.ts b/plugin/src/features/ios/files/swift/widgetBundle.ts
index 3fd9685..06b7558 100644
--- a/plugin/src/features/ios/files/swift/widgetBundle.ts
+++ b/plugin/src/features/ios/files/swift/widgetBundle.ts
@@ -63,7 +63,7 @@ export function generateWidgetBundleSwift(widgets: WidgetConfig[]): string {
@main
struct VoltraWidgetBundle: WidgetBundle {
var body: some Widget {
- // Live Activity Widget (Dynamic Island + Lock Screen)
+ // Live Activity (with Watch/CarPlay support)
VoltraWidget()
// Home Screen Widgets
@@ -97,7 +97,7 @@ export function generateDefaultWidgetBundleSwift(): string {
@main
struct VoltraWidgetBundle: WidgetBundle {
var body: some Widget {
- // Live Activity Widget (Dynamic Island + Lock Screen)
+ // Live Activity (with Watch/CarPlay support)
VoltraWidget()
}
}
diff --git a/src/live-activity/__tests__/variants.node.test.tsx b/src/live-activity/__tests__/variants.node.test.tsx
index e66da96..4d7b597 100644
--- a/src/live-activity/__tests__/variants.node.test.tsx
+++ b/src/live-activity/__tests__/variants.node.test.tsx
@@ -32,3 +32,80 @@ describe('Variants', () => {
expect(result).toHaveProperty('isl_min')
})
})
+
+describe('Supplemental Activity Families (iOS 18+)', () => {
+ test('supplementalActivityFamilies.small renders to saf_sm key', async () => {
+ const result = await renderLiveActivityToJson({
+ lockScreen: Lock Screen,
+ supplementalActivityFamilies: {
+ small: Watch,
+ },
+ })
+
+ expect(result).toHaveProperty('ls')
+ expect(result).toHaveProperty('saf_sm')
+ })
+
+ test('supplementalActivityFamilies.small content is rendered correctly', async () => {
+ const result = await renderLiveActivityToJson({
+ lockScreen: Lock,
+ supplementalActivityFamilies: {
+ small: (
+
+ Watch Content
+
+ ),
+ },
+ })
+
+ expect(result.saf_sm).toBeDefined()
+ expect(result.saf_sm.t).toBe(11)
+ expect(result.saf_sm.c.t).toBe(0)
+ expect(result.saf_sm.c.c).toBe('Watch Content')
+ })
+
+ test('supplementalActivityFamilies families work with all other variants', async () => {
+ const result = await renderLiveActivityToJson({
+ lockScreen: Lock,
+ island: {
+ expanded: {
+ center: Center,
+ },
+ compact: {
+ leading: CL,
+ trailing: CT,
+ },
+ minimal: Min,
+ },
+ supplementalActivityFamilies: {
+ small: Watch,
+ },
+ })
+
+ expect(result).toHaveProperty('ls')
+ expect(result).toHaveProperty('isl_exp_c')
+ expect(result).toHaveProperty('isl_cmp_l')
+ expect(result).toHaveProperty('isl_cmp_t')
+ expect(result).toHaveProperty('isl_min')
+ expect(result).toHaveProperty('saf_sm')
+ })
+
+ test('omitting supplementalActivityFamilies.small does not add saf_sm key', async () => {
+ const result = await renderLiveActivityToJson({
+ lockScreen: Lock,
+ })
+
+ expect(result).toHaveProperty('ls')
+ expect(result).not.toHaveProperty('saf_sm')
+ })
+
+ test('empty supplementalActivityFamilies object does not add saf_sm key', async () => {
+ const result = await renderLiveActivityToJson({
+ lockScreen: Lock,
+ supplementalActivityFamilies: {},
+ })
+
+ expect(result).toHaveProperty('ls')
+ expect(result).not.toHaveProperty('saf_sm')
+ })
+})
diff --git a/src/live-activity/renderer.ts b/src/live-activity/renderer.ts
index 3b272ca..4bbc3b7 100644
--- a/src/live-activity/renderer.ts
+++ b/src/live-activity/renderer.ts
@@ -51,6 +51,11 @@ export const renderLiveActivityToJson = (variants: LiveActivityVariants): LiveAc
}
}
+ // Add supplemental activity family variants (iOS 18+)
+ if (variants.supplementalActivityFamilies?.small) {
+ renderer.addRootNode('saf_sm', variants.supplementalActivityFamilies.small)
+ }
+
// Render all variants
const result = renderer.render() as LiveActivityJson
diff --git a/src/live-activity/types.ts b/src/live-activity/types.ts
index 9b659fc..b9d4cd9 100644
--- a/src/live-activity/types.ts
+++ b/src/live-activity/types.ts
@@ -26,6 +26,18 @@ export type LiveActivityVariants = {
}
minimal?: ReactNode
}
+ /**
+ * Supplemental activity families for iOS 18+ (watchOS Smart Stack, CarPlay)
+ * Always enabled for all Live Activities
+ */
+ supplementalActivityFamilies?: {
+ /**
+ * Small family for watchOS Smart Stack and CarPlay (iOS 18+)
+ * Should be a simplified version of the lock screen UI
+ * Falls back to compact island regions (leading + trailing) if not provided
+ */
+ small?: ReactNode
+ }
}
/**
@@ -45,6 +57,8 @@ export type LiveActivityVariantsJson = {
isl_cmp_l?: VoltraNodeJson
isl_cmp_t?: VoltraNodeJson
isl_min?: VoltraNodeJson
+ // Supplemental activity families (iOS 18+)
+ saf_sm?: VoltraNodeJson // supplementalActivityFamilies.small (watchOS/CarPlay)
}
/**
diff --git a/website/docs/api/plugin-configuration.md b/website/docs/api/plugin-configuration.md
index b272105..d85140a 100644
--- a/website/docs/api/plugin-configuration.md
+++ b/website/docs/api/plugin-configuration.md
@@ -34,6 +34,7 @@ The Voltra Expo config plugin accepts several configuration options in your `app
### `groupIdentifier` (optional)
App Group identifier for sharing data between your app and the widget extension. Required if you want to:
+
- Forward component events (like button taps) from Live Activities to your JavaScript code
- Share images between your app and the extension
- Use image preloading features
@@ -113,4 +114,3 @@ Array of widget configurations for Home Screen widgets. Each widget will be avai
]
}
```
-
diff --git a/website/docs/development/developing-live-activities.md b/website/docs/development/developing-live-activities.md
index af9df21..646cf53 100644
--- a/website/docs/development/developing-live-activities.md
+++ b/website/docs/development/developing-live-activities.md
@@ -47,6 +47,35 @@ const variants = {
}
```
+### Supplemental Activity Families (iOS 18+, watchOS 11+)
+
+The `supplementalActivityFamilies` variant defines how your Live Activity appears on Apple Watch Smart Stack and CarPlay displays. This variant is optional and works seamlessly with your existing lock screen and Dynamic Island variants.
+
+```typescript
+const variants = {
+ lockScreen: (
+
+ {/* iPhone lock screen content */}
+
+ ),
+ island: {
+ /* Dynamic Island variants for iPhone */
+ },
+ supplementalActivityFamilies: {
+ small: (
+
+ 12 min
+ ETA
+
+ ),
+ },
+}
+```
+
+If `supplementalActivityFamilies.small` is not provided, Voltra will automatically construct it from your Dynamic Island `compact` variant by combining the leading and trailing content in an HStack.
+
+See [Supplemental Activity Families](/development/supplemental-activity-families) for detailed design guidelines.
+
## useLiveActivity
For React development, Voltra provides the `useLiveActivity` hook for integration with the component lifecycle and automatic updates during development.