Skip to content

Commit 029fe49

Browse files
committed
Add tests for live activity click events
* Add tests for live activity click events * Refactor generateTrackingDeepLink helper method to be testable, and move to enum namespace (internal by default) * Update the URL in the example app to be more complex
1 parent e3a8df0 commit 029fe49

5 files changed

Lines changed: 257 additions & 38 deletions

File tree

iOS_SDK/OneSignalDevApp/OneSignalWidgetExtension/OneSignalWidgetExtensionLiveActivity.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import OneSignalLiveActivities
5252
}
5353
Spacer()
5454
}
55-
.onesignalWidgetURL(URL(string: "myapp://product/12345"), context: context)
55+
.onesignalWidgetURL(URL(string: "https://example.com/page?param1=value1&param2=value2#section"), context: context)
5656
// .widgetURL(URL(string: "myapp://product/12345"))
5757
.activitySystemActionForegroundColor(.black)
5858
.activityBackgroundTint(.white)
@@ -77,7 +77,7 @@ import OneSignalLiveActivities
7777
} minimal: {
7878
Text("Min")
7979
}
80-
.onesignalWidgetURL(URL(string: "myapp://product/12345"), context: context)
80+
.onesignalWidgetURL(URL(string: "https://example.com/page?param1=value1&param2=value2#section"), context: context)
8181
// .widgetURL(URL(string: "myapp://product/12345"))
8282
.keylineTint(Color.red)
8383
}
@@ -121,7 +121,7 @@ import OneSignalLiveActivities
121121
.padding([.all], 20)
122122
.activitySystemActionForegroundColor(.black)
123123
.activityBackgroundTint(.white)
124-
.onesignalWidgetURL(URL(string: "myapp://product/12345"), context: context)
124+
.onesignalWidgetURL(URL(string: "https://example.com/page?param1=value1&param2=value2#section"), context: context)
125125
} dynamicIsland: { context in
126126
DynamicIsland {
127127
// Expanded UI goes here. Compose the expanded UI through
@@ -144,7 +144,7 @@ import OneSignalLiveActivities
144144
Text("Min")
145145
}
146146
.keylineTint(Color.red)
147-
.onesignalWidgetURL(URL(string: "myapp://product/12345"), context: context)
147+
.onesignalWidgetURL(URL(string: "https://example.com/page?param1=value1&param2=value2#section"), context: context)
148148
}
149149
}
150150
}
@@ -238,7 +238,7 @@ struct DefaultOneSignalLiveActivityWidget: Widget {
238238
.padding([.all], 20)
239239
.activitySystemActionForegroundColor(.black)
240240
.activityBackgroundTint(.white)
241-
.onesignalWidgetURL(URL(string: "myapp://product/12345"), context: context)
241+
.onesignalWidgetURL(URL(string: "https://example.com/page?param1=value1&param2=value2#section"), context: context)
242242
} dynamicIsland: { context in
243243
DynamicIsland {
244244
// Expanded UI goes here. Compose the expanded UI through
@@ -261,7 +261,7 @@ struct DefaultOneSignalLiveActivityWidget: Widget {
261261
Text("Min")
262262
}
263263
.keylineTint(Color.red)
264-
.onesignalWidgetURL(URL(string: "myapp://product/12345"), context: context)
264+
.onesignalWidgetURL(URL(string: "https://example.com/page?param1=value1&param2=value2#section"), context: context)
265265
}
266266
}
267267
}

iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
3C6299A72BEEA41900649187 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C6299A62BEEA40100649187 /* PrivacyInfo.xcprivacy */; };
101101
3C6299A92BEEA46C00649187 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C6299A82BEEA46C00649187 /* PrivacyInfo.xcprivacy */; };
102102
3C6299AB2BEEA4C000649187 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3C6299AA2BEEA4C000649187 /* PrivacyInfo.xcprivacy */; };
103+
3C64C3322F1066D700693230 /* LiveActivitiesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C64C3312F1066D700693230 /* LiveActivitiesManagerTests.swift */; };
103104
3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */; };
104105
3C7021E32ECF0821001768C6 /* OneSignalFramework.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */; };
105106
3C7021E42ECF0821001768C6 /* OneSignalFramework.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@@ -1326,6 +1327,7 @@
13261327
3C6299A62BEEA40100649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
13271328
3C6299A82BEEA46C00649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
13281329
3C6299AA2BEEA4C000649187 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
1330+
3C64C3312F1066D700693230 /* LiveActivitiesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitiesManagerTests.swift; sourceTree = "<group>"; };
13291331
3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchUserIntegrationTests.swift; sourceTree = "<group>"; };
13301332
3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OneSignalInAppMessagesTests-Bridging-Header.h"; sourceTree = "<group>"; };
13311333
3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAMIntegrationTests.swift; sourceTree = "<group>"; };
@@ -2434,6 +2436,7 @@
24342436
isa = PBXGroup;
24352437
children = (
24362438
4735424C2B8F93340016DB4C /* OSLiveActivitiesExecutorTests.swift */,
2439+
3C64C3312F1066D700693230 /* LiveActivitiesManagerTests.swift */,
24372440
47278E462BD92B4B00562820 /* DefaultLiveActivityAttributesTests.swift */,
24382441
);
24392442
path = OneSignalLiveActivitiesTests;
@@ -4436,6 +4439,7 @@
44364439
buildActionMask = 2147483647;
44374440
files = (
44384441
4735424D2B8F93340016DB4C /* OSLiveActivitiesExecutorTests.swift in Sources */,
4442+
3C64C3322F1066D700693230 /* LiveActivitiesManagerTests.swift in Sources */,
44394443
47278E472BD92B4B00562820 /* DefaultLiveActivityAttributesTests.swift in Sources */,
44404444
);
44414445
runOnlyForDeploymentPostprocessing = 0;

iOS_SDK/OneSignalSDK/OneSignalLiveActivities/Source/OSLiveActivityViewExtensions.swift

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ extension DynamicIsland {
5353
_ url: URL?,
5454
context: ActivityViewContext<T>
5555
) -> DynamicIsland {
56-
return self.widgetURL(generateTrackingDeepLink(originalURL: url, context: context))
56+
return self.widgetURL(LiveActivityTrackingUtils.generateTrackingDeepLink(originalURL: url, context: context))
5757
}
5858
}
5959

@@ -75,40 +75,71 @@ extension View {
7575
/// (e.g., `application(_:open:options:)` in AppDelegate or `onOpenURL` in SwiftUI) using the
7676
/// `OneSignal.LiveActivities.trackClickAndReturnOriginal(url)` method.
7777
@MainActor @preconcurrency public func onesignalWidgetURL<T: OneSignalLiveActivityAttributes>(_ url: URL?, context: ActivityViewContext<T>) -> some View {
78-
return self.widgetURL(generateTrackingDeepLink(originalURL: url, context: context))
78+
return self.widgetURL(LiveActivityTrackingUtils.generateTrackingDeepLink(originalURL: url, context: context))
7979
}
8080
}
8181

82-
// MARK: - Helper Function
82+
// MARK: - Tracking Utilities
8383

84-
@available(iOS 16.1, *)
85-
private func generateTrackingDeepLink<T: OneSignalLiveActivityAttributes>(originalURL: URL?, context: ActivityViewContext<T>) -> URL? {
86-
// Generate a unique click ID
87-
let clickId = UUID().uuidString
88-
89-
// Get activity metadata
90-
let activityId = context.attributes.onesignal.activityId
91-
let activityType = String(describing: T.self)
92-
let notificationId = context.state.onesignal?.notificationId
93-
94-
// Build OneSignal tracking URL
95-
var components = URLComponents()
96-
components.scheme = LiveActivityConstants.Tracking.scheme
97-
components.host = LiveActivityConstants.Tracking.host
98-
components.path = LiveActivityConstants.Tracking.clickPath
99-
100-
var queryItems = [
101-
URLQueryItem(name: LiveActivityConstants.Tracking.clickId, value: clickId),
102-
URLQueryItem(name: LiveActivityConstants.Tracking.activityId, value: activityId),
103-
URLQueryItem(name: LiveActivityConstants.Tracking.activityType, value: activityType),
104-
URLQueryItem(name: LiveActivityConstants.Tracking.notificationId, value: notificationId)
105-
]
106-
107-
if let originalURL = originalURL {
108-
queryItems.append(URLQueryItem(name: LiveActivityConstants.Tracking.redirect, value: originalURL.absoluteString))
84+
/// Utilities for building and managing Live Activity tracking URLs
85+
enum LiveActivityTrackingUtils {
86+
/// Generates a tracking deep link from an original URL and activity context
87+
/// - Parameters:
88+
/// - originalURL: The original URL to track clicks for
89+
/// - context: The activity view context containing metadata
90+
/// - Returns: The tracking URL, or nil if construction failed
91+
@available(iOS 16.1, *)
92+
static func generateTrackingDeepLink<T: OneSignalLiveActivityAttributes>(originalURL: URL?, context: ActivityViewContext<T>) -> URL? {
93+
// Get activity metadata from context
94+
let activityId = context.attributes.onesignal.activityId
95+
let activityType = String(describing: T.self)
96+
let notificationId = context.state.onesignal?.notificationId
97+
98+
return buildTrackingURL(
99+
originalURL: originalURL,
100+
activityId: activityId,
101+
activityType: activityType,
102+
notificationId: notificationId
103+
)
109104
}
110105

111-
components.queryItems = queryItems
112-
113-
return components.url
106+
/// Builds a tracking URL that wraps the original URL with OneSignal tracking parameters
107+
/// - Parameters:
108+
/// - originalURL: The original URL to track clicks for
109+
/// - activityId: The activity identifier
110+
/// - activityType: The activity type name
111+
/// - notificationId: Optional notification ID
112+
/// - Returns: The tracking URL, or nil if construction failed
113+
static func buildTrackingURL(
114+
originalURL: URL?,
115+
activityId: String,
116+
activityType: String,
117+
notificationId: String?
118+
) -> URL? {
119+
// Generate a unique click ID
120+
let clickId = UUID().uuidString
121+
122+
// Build OneSignal tracking URL
123+
var components = URLComponents()
124+
components.scheme = LiveActivityConstants.Tracking.scheme
125+
components.host = LiveActivityConstants.Tracking.host
126+
components.path = LiveActivityConstants.Tracking.clickPath
127+
128+
var queryItems = [
129+
URLQueryItem(name: LiveActivityConstants.Tracking.clickId, value: clickId),
130+
URLQueryItem(name: LiveActivityConstants.Tracking.activityId, value: activityId),
131+
URLQueryItem(name: LiveActivityConstants.Tracking.activityType, value: activityType),
132+
URLQueryItem(name: LiveActivityConstants.Tracking.notificationId, value: notificationId)
133+
]
134+
135+
if let originalURL = originalURL {
136+
// URLQueryItem automatically percent-encodes the value when URLComponents constructs the URL
137+
// This ensures special characters like &, #, ?, etc. in the redirect URL are properly encoded
138+
queryItems.append(URLQueryItem(name: LiveActivityConstants.Tracking.redirect, value: originalURL.absoluteString))
139+
}
140+
141+
components.queryItems = queryItems
142+
143+
return components.url
144+
}
114145
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
Modified MIT License
3+
4+
Copyright 2025 OneSignal
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
1. The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
2. All copies of substantial portions of the Software may only be used in connection
17+
with services provided by OneSignal.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
THE SOFTWARE.
26+
*/
27+
28+
import XCTest
29+
@testable import OneSignalLiveActivities
30+
31+
final class LiveActivitiesManagerTests: XCTestCase {
32+
33+
override func setUpWithError() throws {
34+
}
35+
36+
override func tearDownWithError() throws {
37+
}
38+
39+
// MARK: - Helper Methods
40+
41+
private func createTrackingURL(
42+
from clientURL: URL?,
43+
activityId: String = "test-activity-id",
44+
activityType: String = "TestActivityType",
45+
notificationId: String? = "test-notification-id"
46+
) -> URL? {
47+
return LiveActivityTrackingUtils.buildTrackingURL(
48+
originalURL: clientURL,
49+
activityId: activityId,
50+
activityType: activityType,
51+
notificationId: notificationId
52+
)
53+
}
54+
55+
// MARK: - Tests
56+
57+
func testTrackClickAndReturnOriginal_nonTrackingURL_returnsOriginalURL() throws {
58+
/* Setup */
59+
let originalURL = URL(string: "https://example.com/path")!
60+
61+
/* Then */
62+
XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.trackClickAndReturnOriginal(originalURL), originalURL)
63+
}
64+
65+
func testTrackClickAndReturnOriginal_validTrackingURLWithAllParameters_tracksClickAndReturnsRedirectURL() throws {
66+
/* Setup */
67+
let originalURL = URL(string: "https://example.com/destination")!
68+
let trackingURL = createTrackingURL(from: originalURL)
69+
XCTAssertNotNil(trackingURL)
70+
71+
/* Verify tracking URL structure */
72+
let trackingURLString = trackingURL!.absoluteString
73+
XCTAssertTrue(trackingURLString.starts(with: "onesignal-liveactivity://track/click?"))
74+
XCTAssertTrue(trackingURLString.contains("clickId="))
75+
XCTAssertTrue(trackingURLString.contains("activityId=test-activity-id"))
76+
XCTAssertTrue(trackingURLString.contains("activityType=TestActivityType"))
77+
XCTAssertTrue(trackingURLString.contains("notificationId=test-notification-id"))
78+
XCTAssertTrue(trackingURLString.contains("redirect=https://example.com/destination"))
79+
80+
XCTAssertEqual(trackingURL!.scheme, "onesignal-liveactivity")
81+
XCTAssertEqual(trackingURL!.host, "track")
82+
XCTAssertEqual(trackingURL!.path, "/click")
83+
84+
let components = URLComponents(url: trackingURL!, resolvingAgainstBaseURL: false)
85+
let queryItems = components?.queryItems ?? []
86+
87+
XCTAssertNotNil(queryItems.first(where: { $0.name == "clickId" })?.value)
88+
XCTAssertEqual(queryItems.first(where: { $0.name == "activityId" })?.value, "test-activity-id")
89+
XCTAssertEqual(queryItems.first(where: { $0.name == "activityType" })?.value, "TestActivityType")
90+
XCTAssertEqual(queryItems.first(where: { $0.name == "notificationId" })?.value, "test-notification-id")
91+
XCTAssertEqual(queryItems.first(where: { $0.name == "redirect" })?.value, "https://example.com/destination")
92+
93+
/* Then */
94+
XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.trackClickAndReturnOriginal(trackingURL!), originalURL)
95+
}
96+
97+
func testTrackClickAndReturnOriginal_validTrackingURLWithoutNotificationId_tracksClickAndReturnsRedirectURL() throws {
98+
/* Setup */
99+
let clientURL = URL(string: "https://example.com/destination")!
100+
let trackingURL = createTrackingURL(from: clientURL, notificationId: nil)
101+
XCTAssertNotNil(trackingURL)
102+
103+
/* Then */
104+
XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.trackClickAndReturnOriginal(trackingURL!), clientURL)
105+
}
106+
107+
func testTrackClickAndReturnOriginal_trackingURLMissingRequiredParameters_returnsRedirectURLWithoutTracking() throws {
108+
/* Setup */
109+
let redirectURL = "https://example.com/destination"
110+
let trackingURLString = "onesignal-liveactivity://track/click?activityId=test-activity-id&redirect=\(redirectURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)"
111+
let trackingURL = URL(string: trackingURLString)!
112+
113+
/* When */
114+
let result = OneSignalLiveActivitiesManagerImpl.trackClickAndReturnOriginal(trackingURL)
115+
116+
/* Then */
117+
XCTAssertEqual(result!.absoluteString, redirectURL)
118+
}
119+
120+
func testTrackClickAndReturnOriginal_trackingURLWithNoRedirectParameter_returnsNil() throws {
121+
/* Setup */
122+
let trackingURL = createTrackingURL(from: nil)
123+
XCTAssertNotNil(trackingURL)
124+
125+
/* Then */
126+
XCTAssertNil(OneSignalLiveActivitiesManagerImpl.trackClickAndReturnOriginal(trackingURL!))
127+
}
128+
129+
func testTrackClickAndReturnOriginal_malformedTrackingURL_returnsOriginalURL() throws {
130+
/* Setup */
131+
let malformedURL = URL(string: "liveactivity://foo/wrong-path?clickId=test-click-id")!
132+
133+
/* Then */
134+
XCTAssertEqual(OneSignalLiveActivitiesManagerImpl.trackClickAndReturnOriginal(malformedURL), malformedURL)
135+
}
136+
137+
func testTrackClickAndReturnOriginal_complexURLWithQueryParamsAndFragment_preservesAllComponents() throws {
138+
/* Setup */
139+
let clientURL = URL(string: "https://example.com/page?param1=value1&param2=value2#section")!
140+
let trackingURL = createTrackingURL(from: clientURL)
141+
XCTAssertNotNil(trackingURL)
142+
143+
/* When */
144+
let result = OneSignalLiveActivitiesManagerImpl.trackClickAndReturnOriginal(trackingURL!)
145+
146+
/* Then */
147+
XCTAssertEqual(result!, clientURL)
148+
XCTAssertEqual(result!.scheme, "https")
149+
XCTAssertEqual(result!.host, "example.com")
150+
XCTAssertEqual(result!.path, "/page")
151+
XCTAssertEqual(result!.query, "param1=value1&param2=value2")
152+
XCTAssertEqual(result!.fragment, "section")
153+
}
154+
}

0 commit comments

Comments
 (0)