From bb134f906b88d0104628020cf407fe1d976cf418 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 28 Nov 2025 01:34:01 +0000 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InternalIterableAppIntegration.swift | 17 +++++- .../Utilities/NotificationHelper.swift | 22 +++++++ .../SDK/IterableLiveActivityAttributes.swift | 46 +++++++++++++++ .../SDK/IterableLiveActivityManager.swift | 58 +++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 swift-sdk/SDK/IterableLiveActivityAttributes.swift create mode 100644 swift-sdk/SDK/IterableLiveActivityManager.swift diff --git a/swift-sdk/Internal/InternalIterableAppIntegration.swift b/swift-sdk/Internal/InternalIterableAppIntegration.swift index fb5280b96..3137f8cbc 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,18 @@ struct InternalIterableAppIntegration { ITBError("messageId not found in 'remove' silent push") } } + case .liveActivity(let metadata): + #if canImport(ActivityKit) + if #available(iOS 16.1, *) { + IterableLiveActivityManager.shared.start( + vital: metadata.vital, + duration: metadata.duration, + title: metadata.title + ) + } + #endif + case .iterable, .other: + break } completionHandler?(.noData) 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/SDK/IterableLiveActivityAttributes.swift b/swift-sdk/SDK/IterableLiveActivityAttributes.swift new file mode 100644 index 000000000..8f65ab5a0 --- /dev/null +++ b/swift-sdk/SDK/IterableLiveActivityAttributes.swift @@ -0,0 +1,46 @@ +// +// Copyright © 2025 Iterable. All rights reserved. +// + +import Foundation + +#if canImport(ActivityKit) +import ActivityKit +#endif + +#if canImport(ActivityKit) +@available(iOS 16.1, *) +/// Attributes for the Iterable Live Activity. +/// +/// To use this in your Widget Extension: +/// 1. Import IterableSDK +/// 2. Create an `ActivityConfiguration` using `IterableLiveActivityAttributes`: +/// +/// ```swift +/// struct MyLiveActivityWidget: Widget { +/// var body: some WidgetConfiguration { +/// ActivityConfiguration(for: IterableLiveActivityAttributes.self) { context in +/// // ... Build your UI using context.state (ContentState) ... +/// } dynamicIsland: { context in +/// // ... Build your Dynamic Island UI ... +/// } +/// } +/// } +/// ``` +public struct IterableLiveActivityAttributes: ActivityAttributes, Codable { + public struct ContentState: Codable, Hashable { + public var vital: String + public var duration: TimeInterval + public var title: String + + public init(vital: String, duration: TimeInterval, title: String) { + self.vital = vital + self.duration = duration + self.title = title + } + } + + public init() {} +} +#endif + diff --git a/swift-sdk/SDK/IterableLiveActivityManager.swift b/swift-sdk/SDK/IterableLiveActivityManager.swift new file mode 100644 index 000000000..3b7c2f796 --- /dev/null +++ b/swift-sdk/SDK/IterableLiveActivityManager.swift @@ -0,0 +1,58 @@ +// +// Copyright © 2025 Iterable. All rights reserved. +// + +import Foundation + +#if canImport(ActivityKit) +import ActivityKit +#endif + +#if canImport(ActivityKit) +@available(iOS 16.1, *) +public class IterableLiveActivityManager: NSObject { + public static let shared = IterableLiveActivityManager() + + private override init() {} + + public func start( + vital: String, + duration: TimeInterval, + title: String, + pushType: PushType? = nil + ) { + let attributes = IterableLiveActivityAttributes() + let contentState = IterableLiveActivityAttributes.ContentState( + vital: vital, + duration: duration, + title: title + ) + + do { + let activity = try Activity.request( + attributes: attributes, + contentState: contentState, + pushType: pushType + ) + + Task { + for await pushToken in activity.pushTokenUpdates { + let tokenString = pushToken.map { String(format: "%02x", $0) }.joined() + print("Iterable Live Activity Token: \(tokenString)") + // TODO: Send token to Iterable + } + } + + } catch { + print("Error starting Live Activity: \(error.localizedDescription)") + } + } + + @available(iOS 17.2, *) + public func getPushToStartToken() -> Data? { + return Activity.pushToStartToken + } +} +#endif + + From c63670c9ad31c53783f57c06d6b5f06281591f11 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Tue, 2 Dec 2025 17:20:54 +0000 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- swift-sdk/Internal/InternalIterableAPI.swift | 33 ++++ swift-sdk/SDK/IterableAPI.swift | 23 +++ .../SDK/IterableLiveActivityManager.swift | 146 +++++++++++++++++- 3 files changed, 197 insertions(+), 5 deletions(-) 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/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/IterableLiveActivityManager.swift b/swift-sdk/SDK/IterableLiveActivityManager.swift index 3b7c2f796..57868f7af 100644 --- a/swift-sdk/SDK/IterableLiveActivityManager.swift +++ b/swift-sdk/SDK/IterableLiveActivityManager.swift @@ -13,14 +13,25 @@ import ActivityKit public class IterableLiveActivityManager: NSObject { public static let shared = IterableLiveActivityManager() + /// Currently active activities tracked by their ID + private var activeActivities: [String: Activity] = [:] + private override init() {} + /// Start a Live Activity and register its push token with Iterable + /// - Parameters: + /// - vital: Vital information to display (e.g., "145 BPM") + /// - duration: Duration in seconds + /// - title: Activity title + /// - pushType: Push type for updates (default: .token) + /// - Returns: The activity ID if started successfully + @discardableResult public func start( vital: String, duration: TimeInterval, title: String, pushType: PushType? = nil - ) { + ) -> String? { let attributes = IterableLiveActivityAttributes() let contentState = IterableLiveActivityAttributes.ContentState( vital: vital, @@ -35,23 +46,148 @@ public class IterableLiveActivityManager: NSObject { pushType: pushType ) + let activityId = activity.id + activeActivities[activityId] = activity + + ITBInfo("Live Activity started with ID: \(activityId)") + + // Observe push token updates Task { for await pushToken in activity.pushTokenUpdates { - let tokenString = pushToken.map { String(format: "%02x", $0) }.joined() - print("Iterable Live Activity Token: \(tokenString)") - // TODO: Send token to Iterable + 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 { - print("Error starting Live Activity: \(error.localizedDescription)") + ITBError("Error starting Live Activity: \(error.localizedDescription)") + return nil + } + } + + /// Register a Live Activity push token with Iterable + @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)") + + // Register the token with Iterable + 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")") + } + ) + } + + /// Handle activity state changes + 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") } } + /// Update an existing Live Activity + public func update( + activityId: String, + vital: String, + duration: TimeInterval, + title: String + ) async { + guard let activity = activeActivities[activityId] else { + ITBError("No active Live Activity found with ID: \(activityId)") + return + } + + let contentState = IterableLiveActivityAttributes.ContentState( + vital: vital, + duration: duration, + title: title + ) + + await activity.update(using: contentState) + ITBInfo("Live Activity \(activityId) updated") + } + + /// End a Live Activity + public func end(activityId: String, dismissalPolicy: ActivityUIDismissalPolicy = .default) async { + 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) + ITBInfo("Live Activity \(activityId) ended") + } + + /// End all active Live Activities + public func endAll(dismissalPolicy: ActivityUIDismissalPolicy = .default) async { + for (activityId, activity) in activeActivities { + await activity.end(dismissalPolicy: dismissalPolicy) + ITBInfo("Live Activity \(activityId) ended") + } + activeActivities.removeAll() + } + + /// Get the push-to-start token if available (iOS 17.2+) @available(iOS 17.2, *) public func getPushToStartToken() -> Data? { return Activity.pushToStartToken } + + /// Observe push-to-start token updates (iOS 17.2+) + @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)") + + // Register the push-to-start token with Iterable + 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")") + } + ) + } + } + } + } + + /// Get all active activity IDs + public var activeActivityIds: [String] { + Array(activeActivities.keys) + } } #endif From fd6b5484dc595436ec70b29609da7ef367ad90c1 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Thu, 4 Dec 2025 19:16:15 +0000 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=A8Enable=20JS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- swift-sdk/Internal/IterableHtmlMessageViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swift-sdk/Internal/IterableHtmlMessageViewController.swift b/swift-sdk/Internal/IterableHtmlMessageViewController.swift index 215214c9d..52e9b709b 100644 --- a/swift-sdk/Internal/IterableHtmlMessageViewController.swift +++ b/swift-sdk/Internal/IterableHtmlMessageViewController.swift @@ -356,7 +356,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) From 819f746f7a3d6b5c4901759de4c17dc958c8dcf3 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 5 Dec 2025 00:55:20 +0000 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=A8=20Updated=20webview=20to=20load?= =?UTF-8?q?=20remote=20JS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IterableHtmlMessageViewController.swift | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/swift-sdk/Internal/IterableHtmlMessageViewController.swift b/swift-sdk/Internal/IterableHtmlMessageViewController.swift index 52e9b709b..85121c9ca 100644 --- a/swift-sdk/Internal/IterableHtmlMessageViewController.swift +++ b/swift-sdk/Internal/IterableHtmlMessageViewController.swift @@ -53,7 +53,7 @@ protocol MessageViewControllerDelegate: AnyObject { func messageDeinitialized() } -class IterableHtmlMessageViewController: UIViewController { +class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandler { struct Parameters { let html: String let padding: Padding @@ -128,7 +128,16 @@ 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: "")) + if let wkWebView = webView.view as? WKWebView { + wkWebView.configuration.userContentController.add(self, name: "textHandler") + } + + 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) @@ -217,6 +226,8 @@ class IterableHtmlMessageViewController: UIViewController { private static func createWebView() -> WebViewProtocol { let webView = WKWebView(frame: .zero) webView.scrollView.bounces = false + webView.scrollView.delaysContentTouches = false + webView.isUserInteractionEnabled = true webView.isOpaque = false webView.backgroundColor = UIColor.clear return webView as WebViewProtocol @@ -405,3 +416,9 @@ extension IterableHtmlMessageViewController: WKNavigationDelegate { } } + +extension IterableHtmlMessageViewController { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + print("userContentController Received: \(message.body)") + } +} From faa7902590baaebc9e00f11faf6e8ba2cf767987 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 5 Dec 2025 01:40:06 +0000 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=A7=B9=20Cleanup=20of=20WKWebview=20i?= =?UTF-8?q?mplementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IterableHtmlMessageViewController.swift | 35 +++++++++---------- .../Internal/Utilities/WebViewProtocol.swift | 16 +-------- 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/swift-sdk/Internal/IterableHtmlMessageViewController.swift b/swift-sdk/Internal/IterableHtmlMessageViewController.swift index 85121c9ca..4832f5584 100644 --- a/swift-sdk/Internal/IterableHtmlMessageViewController.swift +++ b/swift-sdk/Internal/IterableHtmlMessageViewController.swift @@ -98,11 +98,9 @@ class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandle 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,19 +126,17 @@ class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandle view.backgroundColor = InAppCalculations.initialViewBackgroundColor(isModal: parameters.isModal) webView.set(position: ViewPosition(width: view.frame.width, height: view.frame.height, center: view.center)) - if let wkWebView = webView.view as? WKWebView { - wkWebView.configuration.userContentController.add(self, name: "textHandler") - } var html = parameters.html if let jsString = parameters.messageMetadata?.message.customPayload?["js"] as? String { + print("jsString: \(jsString)") html += "" } webView.loadHTMLString(html, baseURL: URL(string: "")) webView.set(navigationDelegate: self) - view.addSubview(webView.view) + view.addSubview(webView) } override func viewDidLoad() { @@ -210,7 +206,6 @@ class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandle } private var eventTrackerProvider: () -> MessageViewControllerEventTrackerProtocol? - private var webViewProvider: () -> WebViewProtocol private var parameters: Parameters private var onClickCallback: ((URL) -> Void)? private var delegate: MessageViewControllerDelegate? @@ -218,19 +213,23 @@ class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandle private var linkClicked = false private var clickedLink: String? - private lazy var webView = webViewProvider() - private var eventTracker: MessageViewControllerEventTrackerProtocol? { - eventTrackerProvider() - } - - private static func createWebView() -> WebViewProtocol { - let webView = WKWebView(frame: .zero) + private lazy var webView: WKWebView = { + let contentController = WKUserContentController() + contentController.add(self, 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 @@ -279,11 +278,11 @@ class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandle 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?() @@ -303,7 +302,7 @@ class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandle } } - static func calculateWebViewPosition(webView: WebViewProtocol, + static func calculateWebViewPosition(webView: WKWebView, safeAreaInsets: UIEdgeInsets, parentPosition: ViewPosition, paddingLeft: CGFloat, 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) } From d801006a8f7c5fd2d139c4bd2aff895c6af2bb9e Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 5 Dec 2025 02:20:36 +0000 Subject: [PATCH 6/9] =?UTF-8?q?=E2=9C=A8=20Updated=20Activity=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InternalIterableAppIntegration.swift | 7 +- .../SDK/IterableLiveActivityAttributes.swift | 195 ++++++++++++++--- .../SDK/IterableLiveActivityManager.swift | 199 ++++++++++++------ 3 files changed, 302 insertions(+), 99 deletions(-) diff --git a/swift-sdk/Internal/InternalIterableAppIntegration.swift b/swift-sdk/Internal/InternalIterableAppIntegration.swift index 3137f8cbc..d87409a95 100644 --- a/swift-sdk/Internal/InternalIterableAppIntegration.swift +++ b/swift-sdk/Internal/InternalIterableAppIntegration.swift @@ -166,11 +166,8 @@ struct InternalIterableAppIntegration { case .liveActivity(let metadata): #if canImport(ActivityKit) if #available(iOS 16.1, *) { - IterableLiveActivityManager.shared.start( - vital: metadata.vital, - duration: metadata.duration, - title: metadata.title - ) + IterableLiveActivityManager.shared + .startRunComparison(against: .alex, at: .normal) } #endif case .iterable, .other: diff --git a/swift-sdk/SDK/IterableLiveActivityAttributes.swift b/swift-sdk/SDK/IterableLiveActivityAttributes.swift index 8f65ab5a0..7f3b8e85d 100644 --- a/swift-sdk/SDK/IterableLiveActivityAttributes.swift +++ b/swift-sdk/SDK/IterableLiveActivityAttributes.swift @@ -8,39 +8,180 @@ import Foundation 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. -/// -/// To use this in your Widget Extension: -/// 1. Import IterableSDK -/// 2. Create an `ActivityConfiguration` using `IterableLiveActivityAttributes`: -/// -/// ```swift -/// struct MyLiveActivityWidget: Widget { -/// var body: some WidgetConfiguration { -/// ActivityConfiguration(for: IterableLiveActivityAttributes.self) { context in -/// // ... Build your UI using context.state (ContentState) ... -/// } dynamicIsland: { context in -/// // ... Build your Dynamic Island UI ... -/// } -/// } -/// } -/// ``` +/// 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 { - public var vital: String - public var duration: TimeInterval - public var title: String - - public init(vital: String, duration: TimeInterval, title: String) { - self.vital = vital - self.duration = duration - self.title = title + // 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() {} + public init(opponent: RecordedRun) { + self.opponent = opponent + } } #endif - diff --git a/swift-sdk/SDK/IterableLiveActivityManager.swift b/swift-sdk/SDK/IterableLiveActivityManager.swift index 57868f7af..1d87fff14 100644 --- a/swift-sdk/SDK/IterableLiveActivityManager.swift +++ b/swift-sdk/SDK/IterableLiveActivityManager.swift @@ -16,40 +16,53 @@ public class IterableLiveActivityManager: NSObject { /// 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() {} - /// Start a Live Activity and register its push token with Iterable + // MARK: - Start Run Comparison Live Activity + + /// Start a run comparison Live Activity /// - Parameters: - /// - vital: Vital information to display (e.g., "145 BPM") - /// - duration: Duration in seconds - /// - title: Activity title + /// - 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 start( - vital: String, - duration: TimeInterval, - title: String, + public func startRunComparison( + against runner: RunnerName, + at paceLevel: PaceLevel, pushType: PushType? = nil ) -> String? { - let attributes = IterableLiveActivityAttributes() - let contentState = IterableLiveActivityAttributes.ContentState( - vital: vital, - duration: duration, - title: title - ) + 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: contentState, + contentState: initialState, pushType: pushType ) let activityId = activity.id activeActivities[activityId] = activity + currentOpponent = opponent - ITBInfo("Live Activity started with ID: \(activityId)") + ITBInfo("Run comparison Live Activity started with ID: \(activityId), opponent: \(opponent.runnerName.rawValue) at \(opponent.paceLevel.rawValue)") // Observe push token updates Task { @@ -73,67 +86,81 @@ public class IterableLiveActivityManager: NSObject { } } - /// Register a Live Activity push token with Iterable - @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)") + // 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() - // Register the token with Iterable - 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")") + currentMockActivityId = activityId + mockElapsedSeconds = 0 + + mockUpdateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] _ in + guard let self = self else { return } + self.mockElapsedSeconds += updateInterval + + Task { + await self.updateWithMockData(activityId: activityId) } - ) + } } - /// Handle activity state changes - 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") - } + /// Stop mock updates + public func stopMockUpdates() { + mockUpdateTimer?.invalidate() + mockUpdateTimer = nil + currentMockActivityId = nil + mockElapsedSeconds = 0 } - /// Update an existing Live Activity - public func update( - activityId: String, - vital: String, - duration: TimeInterval, - title: String - ) async { + private func updateWithMockData(activityId: String) async { + guard let opponent = currentOpponent else { 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 } - let contentState = IterableLiveActivityAttributes.ContentState( - vital: vital, - duration: duration, - title: title - ) - await activity.update(using: contentState) - ITBInfo("Live Activity \(activityId) updated") + 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 @@ -141,25 +168,65 @@ public class IterableLiveActivityManager: NSObject { 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 } - /// Get the push-to-start token if available (iOS 17.2+) + // 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 } - /// Observe push-to-start token updates (iOS 17.2+) @available(iOS 17.2, *) public func observePushToStartTokenUpdates() { Task { @@ -167,7 +234,6 @@ public class IterableLiveActivityManager: NSObject { let tokenString = token.map { String(format: "%02x", $0) }.joined() ITBInfo("Push-to-start token received: \(tokenString)") - // Register the push-to-start token with Iterable await MainActor.run { IterableAPI.registerLiveActivityToken( token, @@ -184,11 +250,10 @@ public class IterableLiveActivityManager: NSObject { } } - /// Get all active activity IDs + // MARK: - Accessors + public var activeActivityIds: [String] { Array(activeActivities.keys) } } #endif - - From 518a1380ad71d844dd8b3b49ec03e91a298fa59b Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 5 Dec 2025 02:30:40 +0000 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=8C=89Fix=20the=20JS=20bridge=20to=20?= =?UTF-8?q?native=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IterableHtmlMessageViewController.swift | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/swift-sdk/Internal/IterableHtmlMessageViewController.swift b/swift-sdk/Internal/IterableHtmlMessageViewController.swift index 4832f5584..aca8d61e2 100644 --- a/swift-sdk/Internal/IterableHtmlMessageViewController.swift +++ b/swift-sdk/Internal/IterableHtmlMessageViewController.swift @@ -53,6 +53,19 @@ protocol MessageViewControllerDelegate: AnyObject { func messageDeinitialized() } +/// 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 @@ -129,10 +142,8 @@ class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandle var html = parameters.html if let jsString = parameters.messageMetadata?.message.customPayload?["js"] as? String { - print("jsString: \(jsString)") html += "" } - webView.loadHTMLString(html, baseURL: URL(string: "")) webView.set(navigationDelegate: self) @@ -202,6 +213,7 @@ class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandle deinit { ITBInfo() + webView.configuration.userContentController.removeScriptMessageHandler(forName: "textHandler") delegate?.messageDeinitialized() } @@ -213,9 +225,11 @@ class IterableHtmlMessageViewController: UIViewController, WKScriptMessageHandle private var linkClicked = false private var clickedLink: String? + private lazy var scriptMessageHandler = WeakScriptMessageHandler(delegate: self) + private lazy var webView: WKWebView = { let contentController = WKUserContentController() - contentController.add(self, name: "textHandler") + contentController.add(scriptMessageHandler, name: "textHandler") let config = WKWebViewConfiguration() config.userContentController = contentController From 0e3547d530b385ee82c6284df7bd6ad4fe42d582 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 5 Dec 2025 02:37:54 +0000 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9C=A8Added=20parsing=20of=20message=20f?= =?UTF-8?q?rom=20JS=20layer=20to=20initiate=20LiveActivity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IterableHtmlMessageViewController.swift | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/swift-sdk/Internal/IterableHtmlMessageViewController.swift b/swift-sdk/Internal/IterableHtmlMessageViewController.swift index aca8d61e2..456de2fdf 100644 --- a/swift-sdk/Internal/IterableHtmlMessageViewController.swift +++ b/swift-sdk/Internal/IterableHtmlMessageViewController.swift @@ -432,6 +432,34 @@ extension IterableHtmlMessageViewController: WKNavigationDelegate { extension IterableHtmlMessageViewController { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - print("userContentController Received: \(message.body)") + 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.1, *) { + IterableLiveActivityManager.shared.startRunComparison(against: runner, at: pace) + IterableLiveActivityManager.shared.startMockUpdates( + activityId: IterableLiveActivityManager.shared.activeActivityIds.first ?? "", + updateInterval: 3.0 + ) + } + #endif + + // Dismiss the in-app view + animateWhileLeaving(webView.position) } } From a003ff1ee5c98f7f194b901902d404a5d6f64e4e Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 5 Dec 2025 03:00:08 +0000 Subject: [PATCH 9/9] =?UTF-8?q?=E2=8F=B1=EF=B8=8F=20Fix=20timer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InternalIterableAppIntegration.swift | 2 +- .../IterableHtmlMessageViewController.swift | 10 ++-- .../SDK/IterableLiveActivityManager.swift | 56 +++++++++++++++---- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/swift-sdk/Internal/InternalIterableAppIntegration.swift b/swift-sdk/Internal/InternalIterableAppIntegration.swift index d87409a95..53f67dca8 100644 --- a/swift-sdk/Internal/InternalIterableAppIntegration.swift +++ b/swift-sdk/Internal/InternalIterableAppIntegration.swift @@ -165,7 +165,7 @@ struct InternalIterableAppIntegration { } case .liveActivity(let metadata): #if canImport(ActivityKit) - if #available(iOS 16.1, *) { + if #available(iOS 16.2, *) { IterableLiveActivityManager.shared .startRunComparison(against: .alex, at: .normal) } diff --git a/swift-sdk/Internal/IterableHtmlMessageViewController.swift b/swift-sdk/Internal/IterableHtmlMessageViewController.swift index 456de2fdf..d6cfe4a2e 100644 --- a/swift-sdk/Internal/IterableHtmlMessageViewController.swift +++ b/swift-sdk/Internal/IterableHtmlMessageViewController.swift @@ -450,12 +450,10 @@ extension IterableHtmlMessageViewController { ITBInfo("Starting Live Activity: \(runner.rawValue) at \(pace.rawValue)") #if canImport(ActivityKit) - if #available(iOS 16.1, *) { - IterableLiveActivityManager.shared.startRunComparison(against: runner, at: pace) - IterableLiveActivityManager.shared.startMockUpdates( - activityId: IterableLiveActivityManager.shared.activeActivityIds.first ?? "", - updateInterval: 3.0 - ) + if #available(iOS 16.2, *) { + if let activityId = IterableLiveActivityManager.shared.startRunComparison(against: runner, at: pace) { + IterableLiveActivityManager.shared.startMockUpdates(activityId: activityId, updateInterval: 3.0) + } } #endif diff --git a/swift-sdk/SDK/IterableLiveActivityManager.swift b/swift-sdk/SDK/IterableLiveActivityManager.swift index 1d87fff14..4e4dc5afa 100644 --- a/swift-sdk/SDK/IterableLiveActivityManager.swift +++ b/swift-sdk/SDK/IterableLiveActivityManager.swift @@ -9,7 +9,7 @@ import ActivityKit #endif #if canImport(ActivityKit) -@available(iOS 16.1, *) +@available(iOS 16.2, *) public class IterableLiveActivityManager: NSObject { public static let shared = IterableLiveActivityManager() @@ -95,29 +95,54 @@ public class IterableLiveActivityManager: NSObject { 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 - mockUpdateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] _ in + // Schedule timer on main run loop to ensure consistent firing + DispatchQueue.main.async { [weak self] in guard let self = self else { return } - self.mockElapsedSeconds += updateInterval - - Task { - await self.updateWithMockData(activityId: activityId) + 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() { - mockUpdateTimer?.invalidate() - mockUpdateTimer = nil + 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 { return } + 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) @@ -153,7 +178,18 @@ public class IterableLiveActivityManager: NSObject { return } - await activity.update(using: contentState) + // 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)") }