Skip to content

Commit 73b2453

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 ea1ddcd commit 73b2453

File tree

5 files changed

+257
-38
lines changed

5 files changed

+257
-38
lines changed

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
@@ -150,6 +150,7 @@
150150
3CA283A92B86A30400097465 /* OneSignalCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17E627026B95002D3A5D /* OneSignalCore.framework */; };
151151
3CA283AA2B86A30400097465 /* OneSignalCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DE7D17E627026B95002D3A5D /* OneSignalCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
152152
3CA6CE0A28E4F19B00CA0585 /* OSUserRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA6CE0928E4F19B00CA0585 /* OSUserRequest.swift */; };
153+
3CA7F86D2F0C5B71006C5B65 /* LiveActivitiesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA7F86C2F0C5B71006C5B65 /* LiveActivitiesManagerTests.swift */; };
153154
3CA8B8822BEC2FCB0010ADA1 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; };
154155
3CA8B8832BEC2FCB0010ADA1 /* XCTest.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3C7A39D42B7C18EE0082665E /* XCTest.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
155156
3CBB6C262ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */; };
@@ -1358,6 +1359,7 @@
13581359
3C9AD6D02B228B9200BC1540 /* OSRequestRemoveAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestRemoveAlias.swift; sourceTree = "<group>"; };
13591360
3C9AD6D22B228BB000BC1540 /* OSRequestUpdateProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestUpdateProperties.swift; sourceTree = "<group>"; };
13601361
3CA6CE0928E4F19B00CA0585 /* OSUserRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSUserRequest.swift; sourceTree = "<group>"; };
1362+
3CA7F86C2F0C5B71006C5B65 /* LiveActivitiesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitiesManagerTests.swift; sourceTree = "<group>"; };
13611363
3CBB6C252ED59CCC000FEB02 /* ConsistencyManagerTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsistencyManagerTestHelpers.swift; sourceTree = "<group>"; };
13621364
3CC063932B6D6B6B002BB07F /* OneSignalCore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalCore.m; sourceTree = "<group>"; };
13631365
3CC0639A2B6D7A8C002BB07F /* OneSignalCoreMocks.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalCoreMocks.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -2431,6 +2433,7 @@
24312433
isa = PBXGroup;
24322434
children = (
24332435
4735424C2B8F93340016DB4C /* OSLiveActivitiesExecutorTests.swift */,
2436+
3CA7F86C2F0C5B71006C5B65 /* LiveActivitiesManagerTests.swift */,
24342437
47278E462BD92B4B00562820 /* DefaultLiveActivityAttributesTests.swift */,
24352438
);
24362439
path = OneSignalLiveActivitiesTests;
@@ -4432,6 +4435,7 @@
44324435
buildActionMask = 2147483647;
44334436
files = (
44344437
4735424D2B8F93340016DB4C /* OSLiveActivitiesExecutorTests.swift in Sources */,
4438+
3CA7F86D2F0C5B71006C5B65 /* LiveActivitiesManagerTests.swift in Sources */,
44354439
47278E472BD92B4B00562820 /* DefaultLiveActivityAttributesTests.swift in Sources */,
44364440
);
44374441
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)