diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index 6469095d4..c3e3a7344 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -429,6 +429,39 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { register(token: token.hexString(), onSuccess: onSuccess, onFailure: onFailure) } + /// Register a Live Activity push token + /// This sends the token as a tracked event so it can be associated with the user profile + @available(iOS 16.1, *) + func registerLiveActivityToken(_ token: Data, + activityId: String, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) { + guard isEitherUserIdOrEmailSet() else { + let errorMessage = "Either userId or email must be set to register Live Activity token" + ITBError(errorMessage) + onFailure?(errorMessage, nil) + return + } + + let tokenString = token.hexString() + + // Track the Live Activity token registration as a custom event + // This allows the backend to associate the token with the user + let dataFields: [String: Any] = [ + "liveActivityToken": tokenString, + "activityId": activityId, + "platform": "iOS", + "registeredAt": ISO8601DateFormatter().string(from: Date()) + ] + + ITBInfo("Registering Live Activity token for activity: \(activityId)") + + track("liveActivityTokenRegistered", + dataFields: dataFields, + onSuccess: onSuccess, + onFailure: onFailure) + } + @discardableResult func disableDeviceForCurrentUser(withOnSuccess onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { diff --git a/swift-sdk/Internal/InternalIterableAppIntegration.swift b/swift-sdk/Internal/InternalIterableAppIntegration.swift index fb5280b96..53f67dca8 100644 --- a/swift-sdk/Internal/InternalIterableAppIntegration.swift +++ b/swift-sdk/Internal/InternalIterableAppIntegration.swift @@ -147,7 +147,10 @@ struct InternalIterableAppIntegration { func application(_ application: ApplicationStateProviderProtocol, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: ((UIBackgroundFetchResult) -> Void)?) { ITBInfo() - if case let NotificationInfo.silentPush(silentPush) = NotificationHelper.inspect(notification: userInfo) { + let notificationInfo = NotificationHelper.inspect(notification: userInfo) + + switch notificationInfo { + case .silentPush(let silentPush): switch silentPush.notificationType { case .update: _ = inAppNotifiable?.scheduleSync() @@ -160,6 +163,15 @@ struct InternalIterableAppIntegration { ITBError("messageId not found in 'remove' silent push") } } + case .liveActivity(let metadata): + #if canImport(ActivityKit) + if #available(iOS 16.2, *) { + IterableLiveActivityManager.shared + .startRunComparison(against: .alex, at: .normal) + } + #endif + case .iterable, .other: + break } completionHandler?(.noData) diff --git a/swift-sdk/Internal/IterableHtmlMessageViewController.swift b/swift-sdk/Internal/IterableHtmlMessageViewController.swift index 215214c9d..d6cfe4a2e 100644 --- a/swift-sdk/Internal/IterableHtmlMessageViewController.swift +++ b/swift-sdk/Internal/IterableHtmlMessageViewController.swift @@ -53,7 +53,20 @@ protocol MessageViewControllerDelegate: AnyObject { func messageDeinitialized() } -class IterableHtmlMessageViewController: UIViewController { +/// Weak wrapper to avoid retain cycle with WKUserContentController +private class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { + weak var delegate: WKScriptMessageHandler? + + init(delegate: WKScriptMessageHandler) { + self.delegate = delegate + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + delegate?.userContentController(userContentController, didReceive: message) + } +} + +class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandler { struct Parameters { let html: String let padding: Padding @@ -98,11 +111,9 @@ class IterableHtmlMessageViewController: UIViewController { private init(parameters: Parameters, eventTrackerProvider: @escaping @autoclosure () -> MessageViewControllerEventTrackerProtocol?, onClickCallback: ((URL) -> Void)?, - webViewProvider: @escaping @autoclosure () -> WebViewProtocol = IterableHtmlMessageViewController.createWebView(), delegate: MessageViewControllerDelegate?) { ITBInfo() self.eventTrackerProvider = eventTrackerProvider - self.webViewProvider = webViewProvider self.parameters = parameters self.onClickCallback = onClickCallback self.delegate = delegate @@ -128,10 +139,15 @@ class IterableHtmlMessageViewController: UIViewController { view.backgroundColor = InAppCalculations.initialViewBackgroundColor(isModal: parameters.isModal) webView.set(position: ViewPosition(width: view.frame.width, height: view.frame.height, center: view.center)) - webView.loadHTMLString(parameters.html, baseURL: URL(string: "")) + + var html = parameters.html + if let jsString = parameters.messageMetadata?.message.customPayload?["js"] as? String { + html += "" + } + webView.loadHTMLString(html, baseURL: URL(string: "")) webView.set(navigationDelegate: self) - view.addSubview(webView.view) + view.addSubview(webView) } override func viewDidLoad() { @@ -197,11 +213,11 @@ class IterableHtmlMessageViewController: UIViewController { deinit { ITBInfo() + webView.configuration.userContentController.removeScriptMessageHandler(forName: "textHandler") delegate?.messageDeinitialized() } private var eventTrackerProvider: () -> MessageViewControllerEventTrackerProtocol? - private var webViewProvider: () -> WebViewProtocol private var parameters: Parameters private var onClickCallback: ((URL) -> Void)? private var delegate: MessageViewControllerDelegate? @@ -209,17 +225,25 @@ class IterableHtmlMessageViewController: UIViewController { private var linkClicked = false private var clickedLink: String? - private lazy var webView = webViewProvider() - private var eventTracker: MessageViewControllerEventTrackerProtocol? { - eventTrackerProvider() - } + private lazy var scriptMessageHandler = WeakScriptMessageHandler(delegate: self) - private static func createWebView() -> WebViewProtocol { - let webView = WKWebView(frame: .zero) + private lazy var webView: WKWebView = { + let contentController = WKUserContentController() + contentController.add(scriptMessageHandler, name: "textHandler") + + let config = WKWebViewConfiguration() + config.userContentController = contentController + let webView = WKWebView(frame: .zero, configuration: config) webView.scrollView.bounces = false + webView.scrollView.delaysContentTouches = false + webView.isUserInteractionEnabled = true webView.isOpaque = false webView.backgroundColor = UIColor.clear - return webView as WebViewProtocol + return webView + }() + + private var eventTracker: MessageViewControllerEventTrackerProtocol? { + eventTrackerProvider() } /// Resizes the webview based upon the insetPadding, height etc @@ -268,11 +292,11 @@ class IterableHtmlMessageViewController: UIViewController { private func applyAnimation(animationDetail: InAppCalculations.AnimationDetail, completion: (() -> Void)? = nil) { Self.animate(duration: parameters.animationDuration) { [weak self] in self?.webView.set(position: animationDetail.initial.position) - self?.webView.view.alpha = animationDetail.initial.alpha + self?.webView.alpha = animationDetail.initial.alpha self?.view.backgroundColor = animationDetail.initial.bgColor } finalValues: { [weak self] in self?.webView.set(position: animationDetail.final.position) - self?.webView.view.alpha = animationDetail.final.alpha + self?.webView.alpha = animationDetail.final.alpha self?.view.backgroundColor = animationDetail.final.bgColor } completion: { completion?() @@ -292,7 +316,7 @@ class IterableHtmlMessageViewController: UIViewController { } } - static func calculateWebViewPosition(webView: WebViewProtocol, + static func calculateWebViewPosition(webView: WKWebView, safeAreaInsets: UIEdgeInsets, parentPosition: ViewPosition, paddingLeft: CGFloat, @@ -356,7 +380,7 @@ extension IterableHtmlMessageViewController: WKNavigationDelegate { @available(iOS 13.0, *) func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences, decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { if #available(iOS 14.0, *) { - preferences.allowsContentJavaScript = false + preferences.allowsContentJavaScript = true } guard navigationAction.navigationType == .linkActivated, let url = navigationAction.request.url else { decisionHandler(.allow, preferences) @@ -405,3 +429,35 @@ extension IterableHtmlMessageViewController: WKNavigationDelegate { } } + +extension IterableHtmlMessageViewController { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let messageString = message.body as? String else { return } + + let components = messageString + .lowercased() + .trimmingCharacters(in: .whitespaces) + .split(separator: "|") + .map { $0.trimmingCharacters(in: .whitespaces) } + + guard components.count == 2, + let runner = RunnerName.allCases.first(where: { $0.rawValue.lowercased() == components[0] }), + let pace = PaceLevel.allCases.first(where: { $0.rawValue.lowercased() == components[1] }) else { + ITBError("Failed to parse challenge message: \(messageString)") + return + } + + ITBInfo("Starting Live Activity: \(runner.rawValue) at \(pace.rawValue)") + + #if canImport(ActivityKit) + if #available(iOS 16.2, *) { + if let activityId = IterableLiveActivityManager.shared.startRunComparison(against: runner, at: pace) { + IterableLiveActivityManager.shared.startMockUpdates(activityId: activityId, updateInterval: 3.0) + } + } + #endif + + // Dismiss the in-app view + animateWhileLeaving(webView.position) + } +} diff --git a/swift-sdk/Internal/Utilities/NotificationHelper.swift b/swift-sdk/Internal/Utilities/NotificationHelper.swift index 469b7d6da..96b2f8d42 100644 --- a/swift-sdk/Internal/Utilities/NotificationHelper.swift +++ b/swift-sdk/Internal/Utilities/NotificationHelper.swift @@ -9,9 +9,25 @@ import Foundation enum NotificationInfo { case silentPush(ITBLSilentPushNotificationInfo) case iterable(IterablePushNotificationMetadata) + case liveActivity(IterableLiveActivityMetadata) case other } +struct IterableLiveActivityMetadata { + let vital: String + let duration: TimeInterval + let title: String + + static func parse(info: [String: Any]) -> IterableLiveActivityMetadata? { + guard let vital = info["vital"] as? String, + let duration = info["duration"] as? TimeInterval, + let title = info["title"] as? String else { + return nil + } + return IterableLiveActivityMetadata(vital: vital, duration: duration, title: title) + } +} + struct ITBLSilentPushNotificationInfo { let notificationType: ITBLSilentPushNotificationType let messageId: String? @@ -44,6 +60,11 @@ struct NotificationHelper { return .other } + if let liveActivityInfo = itblElement[Keys.liveActivity.rawValue] as? [String: Any], + let metadata = IterableLiveActivityMetadata.parse(info: liveActivityInfo) { + return .liveActivity(metadata) + } + if isGhostPush { if let silentPush = ITBLSilentPushNotificationInfo.parse(notification: notification) { return .silentPush(silentPush) @@ -66,6 +87,7 @@ struct NotificationHelper { private enum Keys: String { case itbl case isGhostPush + case liveActivity } } diff --git a/swift-sdk/Internal/Utilities/WebViewProtocol.swift b/swift-sdk/Internal/Utilities/WebViewProtocol.swift index 13cf7380a..58e7d4026 100644 --- a/swift-sdk/Internal/Utilities/WebViewProtocol.swift +++ b/swift-sdk/Internal/Utilities/WebViewProtocol.swift @@ -11,21 +11,7 @@ struct ViewPosition: Equatable { var center: CGPoint = CGPoint.zero } -protocol WebViewProtocol { - var view: UIView { get } - var position: ViewPosition { get } - @discardableResult func loadHTMLString(_ string: String, baseURL: URL?) -> WKNavigation? - func set(position: ViewPosition) - func set(navigationDelegate: WKNavigationDelegate?) - func layoutSubviews() - func calculateHeight() -> Pending -} - -extension WKWebView: WebViewProtocol { - var view: UIView { - self - } - +extension WKWebView { var position: ViewPosition { ViewPosition(width: frame.size.width, height: frame.size.height, center: center) } diff --git a/swift-sdk/SDK/IterableAPI.swift b/swift-sdk/SDK/IterableAPI.swift index e79a06b56..1e7602689 100644 --- a/swift-sdk/SDK/IterableAPI.swift +++ b/swift-sdk/SDK/IterableAPI.swift @@ -272,6 +272,29 @@ import UIKit implementation.register(token: token, onSuccess: onSuccess, onFailure: onFailure) } + // MARK: - Live Activity Token Registration + + /// Register a Live Activity push token with Iterable + /// + /// - Parameters: + /// - token: The push token for the Live Activity + /// - activityId: The unique identifier for the Live Activity + /// - onSuccess: `OnSuccessHandler` to invoke if registration is successful + /// - onFailure: `OnFailureHandler` to invoke if registration fails + @available(iOS 16.1, *) + public static func registerLiveActivityToken( + _ token: Data, + activityId: String, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil + ) { + guard let implementation, implementation.isSDKInitialized() else { + onFailure?("SDK not initialized", nil) + return + } + implementation.registerLiveActivityToken(token, activityId: activityId, onSuccess: onSuccess, onFailure: onFailure) + } + @objc(pauseAuthRetries:) public static func pauseAuthRetries(_ pauseRetry: Bool) { implementation?.authManager.pauseAuthRetries(pauseRetry) diff --git a/swift-sdk/SDK/IterableLiveActivityAttributes.swift b/swift-sdk/SDK/IterableLiveActivityAttributes.swift new file mode 100644 index 000000000..7f3b8e85d --- /dev/null +++ b/swift-sdk/SDK/IterableLiveActivityAttributes.swift @@ -0,0 +1,187 @@ +// +// Copyright © 2025 Iterable. All rights reserved. +// + +import Foundation + +#if canImport(ActivityKit) +import ActivityKit +#endif + +// MARK: - Run Data Models + +/// Available runners to compare against +public enum RunnerName: String, CaseIterable, Codable { + case alex = "Alex" + case jamie = "Jamie" + case taylor = "Taylor" + case sam = "Sam" +} + +/// Pace levels for pre-recorded runs +public enum PaceLevel: String, CaseIterable, Codable { + case easy = "Easy" + case normal = "Normal" + case fast = "Fast" + case extreme = "Extreme" +} + +/// Pre-recorded run data for comparison +public struct RecordedRun: Codable, Hashable { + public let runnerName: RunnerName + public let paceLevel: PaceLevel + /// Pace in seconds per kilometer + public let paceSecondsPerKm: Int + /// Heart rate in BPM + public let bpm: Int + + public init(runnerName: RunnerName, paceLevel: PaceLevel, paceSecondsPerKm: Int, bpm: Int) { + self.runnerName = runnerName + self.paceLevel = paceLevel + self.paceSecondsPerKm = paceSecondsPerKm + self.bpm = bpm + } + + /// Formatted pace string (e.g., "5:30/km") + public var formattedPace: String { + let minutes = paceSecondsPerKm / 60 + let seconds = paceSecondsPerKm % 60 + return String(format: "%d:%02d/km", minutes, seconds) + } +} + +/// Static data store for pre-recorded runs +public struct RunDataStore { + + // MARK: - Pre-recorded Run Data + // Pace in seconds/km, BPM for each runner at each pace level + + private static let runData: [RunnerName: [PaceLevel: (pace: Int, bpm: Int)]] = [ + .alex: [ + .easy: (pace: 390, bpm: 125), // 6:30/km + .normal: (pace: 330, bpm: 145), // 5:30/km + .fast: (pace: 285, bpm: 165), // 4:45/km + .extreme: (pace: 240, bpm: 185) // 4:00/km + ], + .jamie: [ + .easy: (pace: 420, bpm: 120), // 7:00/km + .normal: (pace: 360, bpm: 140), // 6:00/km + .fast: (pace: 300, bpm: 160), // 5:00/km + .extreme: (pace: 255, bpm: 180) // 4:15/km + ], + .taylor: [ + .easy: (pace: 375, bpm: 130), // 6:15/km + .normal: (pace: 315, bpm: 150), // 5:15/km + .fast: (pace: 270, bpm: 170), // 4:30/km + .extreme: (pace: 225, bpm: 190) // 3:45/km + ], + .sam: [ + .easy: (pace: 405, bpm: 122), // 6:45/km + .normal: (pace: 345, bpm: 142), // 5:45/km + .fast: (pace: 292, bpm: 162), // 4:52/km + .extreme: (pace: 248, bpm: 182) // 4:08/km + ] + ] + + /// Our fixed pace for the mocked current run (5:20/km = 320 seconds/km) + public static let currentRunnerPaceSecondsPerKm: Int = 320 + public static let currentRunnerBaseBpm: Int = 148 + + /// Get a pre-recorded run for a runner at a specific pace level + public static func getRecordedRun(runner: RunnerName, pace: PaceLevel) -> RecordedRun { + let data = runData[runner]![pace]! + return RecordedRun( + runnerName: runner, + paceLevel: pace, + paceSecondsPerKm: data.pace, + bpm: data.bpm + ) + } + + /// Calculate distance covered at a given pace over elapsed time + /// - Parameters: + /// - paceSecondsPerKm: Pace in seconds per kilometer + /// - elapsedSeconds: Total elapsed time in seconds + /// - Returns: Distance in meters + public static func distanceForPace(_ paceSecondsPerKm: Int, elapsedSeconds: TimeInterval) -> Double { + guard paceSecondsPerKm > 0 else { return 0 } + return (elapsedSeconds / Double(paceSecondsPerKm)) * 1000.0 + } +} + +#if canImport(ActivityKit) +@available(iOS 16.1, *) +/// Attributes for the Iterable Live Activity - Run Tracking with Comparison +public struct IterableLiveActivityAttributes: ActivityAttributes, Codable { + + /// The opponent's pre-recorded run we're comparing against + public let opponent: RecordedRun + + public struct ContentState: Codable, Hashable { + // Current runner stats + public let elapsedSeconds: TimeInterval + public let currentDistanceMeters: Double + public let currentPaceSecondsPerKm: Int + public let currentBpm: Int + + // Comparison stats + public let opponentDistanceMeters: Double + /// Positive = ahead, negative = behind + public let distanceDifferenceMeters: Double + + public init( + elapsedSeconds: TimeInterval, + currentDistanceMeters: Double, + currentPaceSecondsPerKm: Int, + currentBpm: Int, + opponentDistanceMeters: Double, + distanceDifferenceMeters: Double + ) { + self.elapsedSeconds = elapsedSeconds + self.currentDistanceMeters = currentDistanceMeters + self.currentPaceSecondsPerKm = currentPaceSecondsPerKm + self.currentBpm = currentBpm + self.opponentDistanceMeters = opponentDistanceMeters + self.distanceDifferenceMeters = distanceDifferenceMeters + } + + // MARK: - Formatted Values + + public var formattedElapsedTime: String { + let minutes = Int(elapsedSeconds) / 60 + let seconds = Int(elapsedSeconds) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + public var formattedCurrentPace: String { + let minutes = currentPaceSecondsPerKm / 60 + let seconds = currentPaceSecondsPerKm % 60 + return String(format: "%d:%02d/km", minutes, seconds) + } + + public var formattedCurrentDistance: String { + if currentDistanceMeters >= 1000 { + return String(format: "%.2f km", currentDistanceMeters / 1000) + } + return String(format: "%.0f m", currentDistanceMeters) + } + + public var formattedDistanceDifference: String { + let absDistance = abs(distanceDifferenceMeters) + let prefix = distanceDifferenceMeters >= 0 ? "+" : "-" + if absDistance >= 1000 { + return String(format: "%@%.2f km", prefix, absDistance / 1000) + } + return String(format: "%@%.0f m", prefix, absDistance) + } + + public var isAhead: Bool { + distanceDifferenceMeters >= 0 + } + } + + public init(opponent: RecordedRun) { + self.opponent = opponent + } +} +#endif diff --git a/swift-sdk/SDK/IterableLiveActivityManager.swift b/swift-sdk/SDK/IterableLiveActivityManager.swift new file mode 100644 index 000000000..4e4dc5afa --- /dev/null +++ b/swift-sdk/SDK/IterableLiveActivityManager.swift @@ -0,0 +1,295 @@ +// +// Copyright © 2025 Iterable. All rights reserved. +// + +import Foundation + +#if canImport(ActivityKit) +import ActivityKit +#endif + +#if canImport(ActivityKit) +@available(iOS 16.2, *) +public class IterableLiveActivityManager: NSObject { + public static let shared = IterableLiveActivityManager() + + /// Currently active activities tracked by their ID + private var activeActivities: [String: Activity] = [:] + + /// Timer for mock updates + private var mockUpdateTimer: Timer? + private var mockElapsedSeconds: TimeInterval = 0 + private var currentMockActivityId: String? + private var currentOpponent: RecordedRun? + + private override init() {} + + // MARK: - Start Run Comparison Live Activity + + /// Start a run comparison Live Activity + /// - Parameters: + /// - runner: The runner to compare against + /// - paceLevel: The pace level of the opponent's run + /// - pushType: Push type for updates (default: .token) + /// - Returns: The activity ID if started successfully + @discardableResult + public func startRunComparison( + against runner: RunnerName, + at paceLevel: PaceLevel, + pushType: PushType? = nil + ) -> String? { + let opponent = RunDataStore.getRecordedRun(runner: runner, pace: paceLevel) + return startRunComparison(opponent: opponent, pushType: pushType) + } + + /// Start a run comparison Live Activity with a pre-configured opponent + @discardableResult + public func startRunComparison( + opponent: RecordedRun, + pushType: PushType? = nil + ) -> String? { + let attributes = IterableLiveActivityAttributes(opponent: opponent) + let initialState = createContentState(elapsedSeconds: 0, opponent: opponent) + + do { + let activity = try Activity.request( + attributes: attributes, + contentState: initialState, + pushType: pushType + ) + + let activityId = activity.id + activeActivities[activityId] = activity + currentOpponent = opponent + + ITBInfo("Run comparison Live Activity started with ID: \(activityId), opponent: \(opponent.runnerName.rawValue) at \(opponent.paceLevel.rawValue)") + + // Observe push token updates + Task { + for await pushToken in activity.pushTokenUpdates { + await self.registerLiveActivityToken(pushToken, activityId: activityId) + } + } + + // Observe activity state changes + Task { + for await state in activity.activityStateUpdates { + self.handleActivityStateChange(activityId: activityId, state: state) + } + } + + return activityId + + } catch { + ITBError("Error starting Live Activity: \(error.localizedDescription)") + return nil + } + } + + // MARK: - Mock Updates + + /// Start mock updates for demo purposes + /// - Parameters: + /// - activityId: The activity to update + /// - updateInterval: Time between updates in seconds (default: 1.0) + public func startMockUpdates(activityId: String, updateInterval: TimeInterval = 1.0) { + stopMockUpdates() + + guard !activityId.isEmpty else { + ITBError("Cannot start mock updates with empty activity ID") + return + } + + currentMockActivityId = activityId + mockElapsedSeconds = 0 + + // Schedule timer on main run loop to ensure consistent firing + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.mockUpdateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] _ in + guard let self = self, + let activityId = self.currentMockActivityId else { return } + self.mockElapsedSeconds += updateInterval + + Task { @MainActor in + await self.updateWithMockData(activityId: activityId) + } + } + // Ensure timer fires even when scrolling + RunLoop.main.add(self.mockUpdateTimer!, forMode: .common) + } + + ITBInfo("Mock updates started for activity \(activityId) with interval \(updateInterval)s") + } + + /// Stop mock updates + public func stopMockUpdates() { + DispatchQueue.main.async { [weak self] in + self?.mockUpdateTimer?.invalidate() + self?.mockUpdateTimer = nil + } + currentMockActivityId = nil + mockElapsedSeconds = 0 + ITBInfo("Mock updates stopped") + } + + private func updateWithMockData(activityId: String) async { + guard let opponent = currentOpponent else { + ITBError("No opponent set for mock updates") + return + } + guard activeActivities[activityId] != nil else { + ITBError("Activity \(activityId) no longer active, stopping mock updates") + stopMockUpdates() + return + } + + let contentState = createContentState(elapsedSeconds: mockElapsedSeconds, opponent: opponent) + await update(activityId: activityId, contentState: contentState) + } + + // MARK: - Content State Creation + + private func createContentState(elapsedSeconds: TimeInterval, opponent: RecordedRun) -> IterableLiveActivityAttributes.ContentState { + let currentPace = RunDataStore.currentRunnerPaceSecondsPerKm + let currentDistance = RunDataStore.distanceForPace(currentPace, elapsedSeconds: elapsedSeconds) + let opponentDistance = RunDataStore.distanceForPace(opponent.paceSecondsPerKm, elapsedSeconds: elapsedSeconds) + + // Add slight BPM variation for realism + let bpmVariation = Int.random(in: -3...3) + let currentBpm = RunDataStore.currentRunnerBaseBpm + bpmVariation + + return IterableLiveActivityAttributes.ContentState( + elapsedSeconds: elapsedSeconds, + currentDistanceMeters: currentDistance, + currentPaceSecondsPerKm: currentPace, + currentBpm: currentBpm, + opponentDistanceMeters: opponentDistance, + distanceDifferenceMeters: currentDistance - opponentDistance + ) + } + + // MARK: - Update & End + + /// Update an existing Live Activity with new content state + public func update(activityId: String, contentState: IterableLiveActivityAttributes.ContentState) async { + guard let activity = activeActivities[activityId] else { + ITBError("No active Live Activity found with ID: \(activityId)") + return + } + + // Check if activity is still active + guard activity.activityState == .active else { + ITBError("Live Activity \(activityId) is not active (state: \(activity.activityState))") + return + } + + await activity.update( + ActivityContent( + state: contentState, + staleDate: Date().addingTimeInterval(60) // Mark stale after 60s without update + ) + ) + ITBInfo("Live Activity \(activityId) updated - elapsed: \(contentState.formattedElapsedTime), diff: \(contentState.formattedDistanceDifference)") + } + + /// End a Live Activity + public func end(activityId: String, dismissalPolicy: ActivityUIDismissalPolicy = .default) async { + stopMockUpdates() + + guard let activity = activeActivities[activityId] else { + ITBError("No active Live Activity found with ID: \(activityId)") + return + } + + await activity.end(dismissalPolicy: dismissalPolicy) + activeActivities.removeValue(forKey: activityId) + currentOpponent = nil + ITBInfo("Live Activity \(activityId) ended") + } + + /// End all active Live Activities + public func endAll(dismissalPolicy: ActivityUIDismissalPolicy = .default) async { + stopMockUpdates() + + for (activityId, activity) in activeActivities { + await activity.end(dismissalPolicy: dismissalPolicy) + ITBInfo("Live Activity \(activityId) ended") + } + activeActivities.removeAll() + currentOpponent = nil + } + + // MARK: - Token Management + + @MainActor + private func registerLiveActivityToken(_ token: Data, activityId: String) { + let tokenString = token.map { String(format: "%02x", $0) }.joined() + ITBInfo("Live Activity Token received for activity \(activityId): \(tokenString)") + + IterableAPI.registerLiveActivityToken( + token, + activityId: activityId, + onSuccess: { _ in + ITBInfo("Live Activity token registered successfully") + }, + onFailure: { reason, _ in + ITBError("Failed to register Live Activity token: \(reason ?? "unknown error")") + } + ) + } + + private func handleActivityStateChange(activityId: String, state: ActivityState) { + switch state { + case .active: + ITBInfo("Live Activity \(activityId) is active") + case .ended: + ITBInfo("Live Activity \(activityId) ended") + activeActivities.removeValue(forKey: activityId) + case .dismissed: + ITBInfo("Live Activity \(activityId) dismissed") + activeActivities.removeValue(forKey: activityId) + case .stale: + ITBInfo("Live Activity \(activityId) is stale") + @unknown default: + ITBInfo("Live Activity \(activityId) unknown state") + } + } + + // MARK: - Push-to-Start (iOS 17.2+) + + @available(iOS 17.2, *) + public func getPushToStartToken() -> Data? { + return Activity.pushToStartToken + } + + @available(iOS 17.2, *) + public func observePushToStartTokenUpdates() { + Task { + for await token in Activity.pushToStartTokenUpdates { + let tokenString = token.map { String(format: "%02x", $0) }.joined() + ITBInfo("Push-to-start token received: \(tokenString)") + + await MainActor.run { + IterableAPI.registerLiveActivityToken( + token, + activityId: "push-to-start", + onSuccess: { _ in + ITBInfo("Push-to-start token registered successfully") + }, + onFailure: { reason, _ in + ITBError("Failed to register push-to-start token: \(reason ?? "unknown error")") + } + ) + } + } + } + } + + // MARK: - Accessors + + public var activeActivityIds: [String] { + Array(activeActivities.keys) + } +} +#endif