From ec2575284b3c2136a0c79b39c97cbfc6702b0f37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Sa=CC=81nchez=20Pardo?= Date: Fri, 24 Oct 2025 23:47:21 +0200 Subject: [PATCH 01/10] Add campaign resolving case and call it from universal link --- README.md | 4 +- .../Private/{ => API}/APIProvider.swift | 26 +++ .../Private/API/CampaignResponse.swift | 13 ++ .../PostInstallLinkSearchResponse.swift | 1 + .../Traceback/Private/CampaignTracker.swift | 52 ++++++ .../Private/DependenciesImpl/LoggerImpl.swift | 6 +- .../DependenciesImpl/SystemInfoImpl.swift | 12 +- .../Traceback/Private/DeviceFingerprint.swift | 3 + .../Traceback/Private/TracebackSDKImpl.swift | 174 +++++++++++++++--- .../Traceback/TracebackConfiguration.swift | 4 + Sources/Traceback/TracebackSDK.swift | 47 ++--- Tests/TracebackTests/AdditionalTests.swift | 26 +-- Tests/TracebackTests/NetworkTests.swift | 2 +- Tests/TracebackTests/TracebackTests.swift | 2 + 14 files changed, 291 insertions(+), 81 deletions(-) rename Sources/Traceback/Private/{ => API}/APIProvider.swift (52%) create mode 100644 Sources/Traceback/Private/API/CampaignResponse.swift rename Sources/Traceback/Private/{ => API}/PostInstallLinkSearchResponse.swift (95%) create mode 100644 Sources/Traceback/Private/CampaignTracker.swift diff --git a/README.md b/README.md index 7bae44a..e7ea39e 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The diagnostics will categorize issues as: The result object returned by `postInstallSearchLink()` and `extractLinkFromURL()` contains: - `url: URL?` - The extracted deep link URL to navigate to -- `match_type: MatchType` - How the link was detected (`.unique`, `.default`, `.none`, `.unknown`) +- `matchType: MatchType` - How the link was detected (`.unique`, `.heuristics`, `.ambiguous`, `.none`) - `analytics: [TracebackAnalyticsEvent]` - Analytics events you can send to your preferred platform ### TracebackConfiguration @@ -283,4 +283,4 @@ do { - Test on physical devices for Universal Links validation - Use different network conditions - Test both fresh installs and existing app scenarios -- Verify clipboard functionality works as expected \ No newline at end of file +- Verify clipboard functionality works as expected diff --git a/Sources/Traceback/Private/APIProvider.swift b/Sources/Traceback/Private/API/APIProvider.swift similarity index 52% rename from Sources/Traceback/Private/APIProvider.swift rename to Sources/Traceback/Private/API/APIProvider.swift index 2403c3b..07e1aed 100644 --- a/Sources/Traceback/Private/APIProvider.swift +++ b/Sources/Traceback/Private/API/APIProvider.swift @@ -33,4 +33,30 @@ struct APIProvider: Sendable { throw NetworkError(error: error) } } + + func getCampaignLink(from url: String, isFirstCampaignOpen: Bool) async throws -> CampaignResponse { + var urlComponents = URLComponents( + url: config.host.appendingPathComponent("v1_getCampaign", isDirectory: false), + resolvingAgainstBaseURL: false + ) + + // Add query parameters + urlComponents?.queryItems = [ + URLQueryItem(name: "link", value: url), + URLQueryItem(name: "isFirstCampaignOpen", value: String(isFirstCampaignOpen)) + ] + + guard let url = urlComponents?.url else { + throw NetworkError.unknown + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + do { + return try await network.fetch(CampaignResponse.self, request: request) + } catch { + throw NetworkError(error: error) + } + } } diff --git a/Sources/Traceback/Private/API/CampaignResponse.swift b/Sources/Traceback/Private/API/CampaignResponse.swift new file mode 100644 index 0000000..67f40bb --- /dev/null +++ b/Sources/Traceback/Private/API/CampaignResponse.swift @@ -0,0 +1,13 @@ +// +// CampaignResponse.swift +// traceback-ios +// +// Created by Nacho Sánchez on 10/24/25. +// + +import Foundation + +struct CampaignResponse: Decodable, Equatable, Sendable { + let result: URL? + let error: String? +} diff --git a/Sources/Traceback/Private/PostInstallLinkSearchResponse.swift b/Sources/Traceback/Private/API/PostInstallLinkSearchResponse.swift similarity index 95% rename from Sources/Traceback/Private/PostInstallLinkSearchResponse.swift rename to Sources/Traceback/Private/API/PostInstallLinkSearchResponse.swift index 30ddd3d..fe100bd 100644 --- a/Sources/Traceback/Private/PostInstallLinkSearchResponse.swift +++ b/Sources/Traceback/Private/API/PostInstallLinkSearchResponse.swift @@ -11,6 +11,7 @@ struct PostInstallLinkSearchResponse: Decodable, Equatable, Sendable { let deep_link_id: URL? let match_message: String let match_type: String + let match_campaign: String? let request_ip_version: String let utm_medium: String? let utm_source: String? diff --git a/Sources/Traceback/Private/CampaignTracker.swift b/Sources/Traceback/Private/CampaignTracker.swift new file mode 100644 index 0000000..0bf19df --- /dev/null +++ b/Sources/Traceback/Private/CampaignTracker.swift @@ -0,0 +1,52 @@ +// +// CampaignTracker.swift +// Traceback +// +// Created by Nacho Sanchez on 24/10/25. +// + +import Foundation + +class CampaignTracker { + private let userDefaults = UserDefaults.standard + private let seenCampaignsKey = "seenCampaigns" + + // Get all seen campaigns + private func getSeenCampaigns() -> Set { + if let campaigns = userDefaults.array(forKey: seenCampaignsKey) as? [String] { + return Set(campaigns) + } + return Set() + } + + // Save seen campaigns + private func saveSeenCampaigns(_ campaigns: Set) { + userDefaults.set(Array(campaigns), forKey: seenCampaignsKey) + } + + // Check if campaign has been seen before + func hasSeenCampaign(_ campaign: String) -> Bool { + return getSeenCampaigns().contains(campaign) + } + + // Mark campaign as seen + func markCampaignAsSeen(_ campaign: String) { + var campaigns = getSeenCampaigns() + campaigns.insert(campaign) + saveSeenCampaigns(campaigns) + } + + // Check and mark in one operation (returns true if first time) + func isFirstTimeSeen(_ campaign: String) -> Bool { + let isFirstTime = !hasSeenCampaign(campaign) + if isFirstTime { + markCampaignAsSeen(campaign) + } + return isFirstTime + } + + // Clear all seen campaigns (useful for testing) + func clearSeenCampaigns() { + userDefaults.removeObject(forKey: seenCampaignsKey) + } +} diff --git a/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift b/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift index f0b9594..cf483cd 100644 --- a/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift +++ b/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift @@ -18,14 +18,14 @@ extension Logger { return Logger( info: { message in guard level == .info || level == .debug else { return } - logger.info("\(message())") + logger.info("[Traceback] \(message())") }, debug: { message in guard level == .debug else { return } - logger.debug("\(message())") + logger.debug("[Traceback] \(message())") }, error: { message in - logger.error("\(message())") + logger.error("[Traceback] \(message())") } ) } diff --git a/Sources/Traceback/Private/DependenciesImpl/SystemInfoImpl.swift b/Sources/Traceback/Private/DependenciesImpl/SystemInfoImpl.swift index 13bc113..6cae66f 100644 --- a/Sources/Traceback/Private/DependenciesImpl/SystemInfoImpl.swift +++ b/Sources/Traceback/Private/DependenciesImpl/SystemInfoImpl.swift @@ -14,7 +14,7 @@ enum TracebackSystemImpl { SystemInfo( installationTime: installationTime(), deviceModelName: deviceModelName(), - sdkVersion: sdkVersion(), + sdkVersion: TracebackSDK.sdkVersion, localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier, timezone: TimeZone.current, osVersion: UIDevice.current.systemVersion, @@ -49,14 +49,4 @@ enum TracebackSystemImpl { } #endif } - - private static func sdkVersion() -> String { - guard let infoDictSDKVersion = Bundle(for: TracebackSDKImpl.self) - .infoDictionary?["CFBundleShortVersionString"] as? String else { - // fails in unit test - // assertionFailure() - return "1.0.0" - } - return infoDictSDKVersion - } } diff --git a/Sources/Traceback/Private/DeviceFingerprint.swift b/Sources/Traceback/Private/DeviceFingerprint.swift index 72f64c7..23486e5 100644 --- a/Sources/Traceback/Private/DeviceFingerprint.swift +++ b/Sources/Traceback/Private/DeviceFingerprint.swift @@ -15,6 +15,7 @@ struct DeviceFingerprint: Codable, Equatable, Sendable { let osVersion: String let sdkVersion: String let uniqueMatchLinkToCheck: URL? + let intentLink: URL? let device: DeviceInfo struct DeviceInfo: Codable, Equatable, Sendable { @@ -33,6 +34,7 @@ struct DeviceFingerprint: Codable, Equatable, Sendable { func createDeviceFingerprint( system: SystemInfo, linkFromClipboard: URL?, + linkFromIntent: URL?, webviewInfo: WebViewInfo? ) -> DeviceFingerprint { @@ -65,6 +67,7 @@ func createDeviceFingerprint( osVersion: system.osVersion, sdkVersion: system.sdkVersion, uniqueMatchLinkToCheck: linkFromClipboard, + intentLink: linkFromIntent, device: deviceInfo ) } diff --git a/Sources/Traceback/Private/TracebackSDKImpl.swift b/Sources/Traceback/Private/TracebackSDKImpl.swift index 980f0c0..c1a3c61 100644 --- a/Sources/Traceback/Private/TracebackSDKImpl.swift +++ b/Sources/Traceback/Private/TracebackSDKImpl.swift @@ -12,17 +12,21 @@ private let userDefaultsExistingRunKey = "traceback_existingRun" extension TracebackSDK.Result { static var empty: Self { - TracebackSDK.Result(url: nil, match_type: .none, analytics: []) + TracebackSDK.Result(url: nil, campaign: nil, matchType: .none, analytics: []) } } final class TracebackSDKImpl { private let config: TracebackConfiguration private let logger: Logger + private let campaignTracker: CampaignTracker + + private var universalLinkContinuation: CheckedContinuation? - init(config: TracebackConfiguration, logger: Logger) { + init(config: TracebackConfiguration, logger: Logger, campaignTracker: CampaignTracker) { self.config = config self.logger = logger + self.campaignTracker = campaignTracker } func detectPostInstallLink() async -> TracebackSDK.Result { @@ -34,6 +38,10 @@ final class TracebackSDKImpl { return .empty } + logger.debug("Waiting for universal link") + let linkFromIntent = await waitForUniversalLink(timeout: 0.5) + logger.debug("Got universal link: \(linkFromIntent?.absoluteString ?? "none")") + logger.info("Checking for post-install link") do { @@ -57,6 +65,7 @@ final class TracebackSDKImpl { let fingerprint = await createDeviceFingerprint( system: system, linkFromClipboard: linkFromClipboard, + linkFromIntent: linkFromIntent, webviewInfo: webviewInfo ) @@ -71,61 +80,168 @@ final class TracebackSDKImpl { ) let response = try await api.sendFingerprint(fingerprint) - logger.info("Server responded with match type: \(response.match_type)") + logger.info("Server responded with match type: \(response.matchType)") + // 5. Save checks locally UserDefaults.standard.set(true, forKey: userDefaultsExistingRunKey) logger.info("Post-install success saved to user defaults \(userDefaultsExistingRunKey)" + " so it is no longer checked") - if let deep_link_id = response.deep_link_id { - return TracebackSDK.Result( - url: response.deep_link_id, - match_type: response.matchType, - analytics: [ - .postInstallDetected(deep_link_id) - ] - ) - } else { + if let campaign = response.match_campaign { + campaignTracker.markCampaignAsSeen(campaign) + logger.info("Campaign \(campaign) seen for first time") + } + + // TODO: remove this if backend sends final deeplink + if + let longLink = response.deep_link_id, + let deeplink = try? extractLink(from: longLink) + { return TracebackSDK.Result( - url: response.deep_link_id, - match_type: response.matchType, - analytics: [] + url: deeplink, + campaign: response.match_campaign, + matchType: response.matchType, + analytics: response.deep_link_id.map { [.postInstallDetected($0)] } ?? [] ) } + + // 6. Return what we have found + return TracebackSDK.Result( + url: response.deep_link_id, + campaign: response.match_campaign, + matchType: response.matchType, + analytics: response.deep_link_id.map { [.postInstallDetected($0)] } ?? [] + ) } catch { logger.error("Failed to detect post-install link: \(error.localizedDescription)") return TracebackSDK.Result( url: nil, - match_type: .none, + campaign: nil, + matchType: .none, analytics: [ .postInstallError(error) ] ) } } - - func extractLink(from: URL) throws -> TracebackSDK.Result { + private func waitForUniversalLink(timeout: TimeInterval) async -> URL? { + // Create a cancellation handle + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil as URL? + } + + // Wait for whichever finishes first + let result: URL? = await withCheckedContinuation { continuation in + // Store continuation so it can be resumed externally + self.universalLinkContinuation = continuation + + // Also start the timeout watcher + Task { + let url = await timeoutTask.value + continuation.resume(returning: url) + } + } + + // Whichever won, cancel the other + timeoutTask.cancel() + + return result + } + + func getCampaignLink(from url: URL) async -> TracebackSDK.Result { + do { + // 1. Check if first run, if not save link and continue + guard UserDefaults.standard.bool(forKey: userDefaultsExistingRunKey) else { + logger.info("Do not get campaign links on first run, do it via postInstallSearch") + universalLinkContinuation?.resume(returning: url) + return .empty + } + + // 2. Extract campaign from url + let campaign = extractCampaign(from: url) + + // 3. If no campaign, process locally + guard let campaign else { + let deeplink = try? extractLink(from: url) + + return TracebackSDK.Result( + url: deeplink, + campaign: campaign, + matchType: .unique, + analytics: [] + ) + } + + let isFirstCampaignOpen = campaignTracker.isFirstTimeSeen(campaign) + logger.info("Campaign \(campaign) first open \(isFirstCampaignOpen)") + + // 3. Get campaign resolution from backend + let api = APIProvider( + config: NetworkConfiguration( + host: config.mainAssociatedHost + ), + network: Network.live + ) + + let response = try await api.getCampaignLink(from: url.absoluteString, isFirstCampaignOpen: isFirstCampaignOpen) + logger.info("Server responded with link: \(String(describing: response.result))") + + guard + let deeplink = response.result + else { + return .empty + } + + return TracebackSDK.Result( + url: deeplink, + campaign: campaign, + matchType: .unique, + analytics: [ + .campaignResolved(deeplink) + ] + ) + } catch { + return TracebackSDK.Result( + url: nil, + campaign: nil, + matchType: .none, + analytics: [ + .campaignError(error) + ] + ) + } + } - guard let components = URLComponents(url: from, resolvingAgainstBaseURL: false) else { + /// Parses the url that triggered app launch and extracts the real expected url to be opened + /// + /// @Discussion When a specific content is expected to be opened inside the application. The real url + /// defining the content is not allways plain visible in the url which opened the app, since we need to build + /// a url that is valid for all platforms, and for installation path. This method extracts the real url to be + /// opened. + private func extractLink(from url: URL) throws -> URL? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { throw TracebackError.ExtractLink.invalidURL } + for queryItem in components.queryItems ?? [] { if queryItem.name == "link", let value = queryItem.value, let url = URL(string: value) { - return TracebackSDK.Result( - url: url, - match_type: .unknown, - analytics: [] - ) + return url } } - return TracebackSDK.Result( - url: nil, - match_type: .unknown, - analytics: [] - ) + + return nil + } + + private func extractCampaign(from url: URL) -> String? { + let path = url.path + if path.count > 1 { + return String(path.dropFirst()) + } + return nil } @MainActor diff --git a/Sources/Traceback/TracebackConfiguration.swift b/Sources/Traceback/TracebackConfiguration.swift index 7581fe4..6e76c7f 100644 --- a/Sources/Traceback/TracebackConfiguration.swift +++ b/Sources/Traceback/TracebackConfiguration.swift @@ -13,6 +13,10 @@ public enum TracebackAnalyticsEvent { case postInstallDetected(URL) /// A post-installation content url failure case postInstallError(Error) + /// A get-campaign content url has been resolved + case campaignResolved(URL) + /// A get-campaign content url failure + case campaignError(Error) } /// Main configuration for the traceback sdk. diff --git a/Sources/Traceback/TracebackSDK.swift b/Sources/Traceback/TracebackSDK.swift index e838d04..dbd5e95 100644 --- a/Sources/Traceback/TracebackSDK.swift +++ b/Sources/Traceback/TracebackSDK.swift @@ -11,21 +11,25 @@ import Foundation /// Main SDK entry object offering methods for searching the app opening content url and debug /// public struct TracebackSDK { - /// initialization value passed to TracebackSDK.live + + static let sdkVersion = "0.3.0" + + /// Initialization value passed to TracebackSDK.live public let configuration: TracebackConfiguration + /// Searches for the right content url that triggered app install /// /// @Discussion Calling this method right after app installation will search for the content url that /// was expected to be displayed at the very beginning of app install path. The method will return a /// valid url only once, later calls will no longer search for the opening url. - public let postInstallSearchLink: () async throws -> Result? - /// Parses the url that triggered app launch and extracts the real expected url to be opened + public let postInstallSearchLink: () async throws -> TracebackSDK.Result? + + /// Searches for the right content url associated to the url that opened the app /// - /// @Discussion When a specific content is expected to be opened inside the application. The real url - /// defining the content is not allways plain visible in the url which opened the app, since we need to build - /// a url that is valid for all platforms, and for installation path. This method extracts the real url to be - /// opened. - public let extractLinkFromURL: (URL) throws -> Result? + /// @Discussion Calling this method when the app is opened via Universal Link or scheme + /// will search for the content url associated to the opened url. + public let campaignSearchLink: (URL) async throws -> TracebackSDK.Result? + /// Diagnostics info /// /// @Discussion Call this method at app startup to diagnose your current setup @@ -46,8 +50,10 @@ public struct TracebackSDK { public struct Result { /// A valid url if the method correctly finds a post install link, or opened url contains a valid deep link public let url: URL? + /// The campaign associated to the inspected URL, if any + public let campaign: String? /// The match type when extracting the post install - public let match_type: MatchType + public let matchType: MatchType /// Analytics to be sent to your preferred analytics platform public let analytics: [TracebackAnalyticsEvent] } @@ -65,23 +71,20 @@ public extension TracebackSDK { /// static func live(config: TracebackConfiguration) -> TracebackSDK { let logger = Logger.live(level: config.logLevel) + let campaignTracker = CampaignTracker() + let implementation = TracebackSDKImpl( + config: config, + logger: logger, + campaignTracker: campaignTracker + ) + return TracebackSDK( configuration: config, postInstallSearchLink: { - await TracebackSDKImpl( - config: config, - logger: logger - ) - .detectPostInstallLink() + await implementation.detectPostInstallLink() }, - extractLinkFromURL: { url in - try? TracebackSDKImpl( - config: config, - logger: logger - ) - .extractLink( - from: url - ) + campaignSearchLink: { url in + await implementation.getCampaignLink(from: url) }, performDiagnostics: { Task { @MainActor in diff --git a/Tests/TracebackTests/AdditionalTests.swift b/Tests/TracebackTests/AdditionalTests.swift index dcd644d..bf0de3a 100644 --- a/Tests/TracebackTests/AdditionalTests.swift +++ b/Tests/TracebackTests/AdditionalTests.swift @@ -49,7 +49,7 @@ func testExtractLinkFromURL() throws { let result = try sdk.extractLinkFromURL(urlWithLink) #expect(result?.url?.absoluteString == "https://myapp.com/product/123") - #expect(result?.match_type == TracebackSDK.MatchType.unknown) + #expect(result?.matchType == TracebackSDK.MatchType.unknown) } @Test @@ -64,7 +64,7 @@ func testExtractLinkFromURLWithoutLinkParameter() throws { let result = try sdk.extractLinkFromURL(urlWithoutLink) #expect(result?.url == nil) - #expect(result?.match_type == TracebackSDK.MatchType.unknown) + #expect(result?.matchType == TracebackSDK.MatchType.unknown) } @Test @@ -79,7 +79,7 @@ func testExtractLinkFromURLWithMultipleQueryParams() throws { let result = try sdk.extractLinkFromURL(complexURL) #expect(result?.url?.absoluteString == "https://myapp.com/share/abc") - #expect(result?.match_type == TracebackSDK.MatchType.unknown) + #expect(result?.matchType == TracebackSDK.MatchType.unknown) } // MARK: - Response Model Tests @@ -90,7 +90,7 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { let uniqueResponse = PostInstallLinkSearchResponse( deep_link_id: URL(string: "https://example.com/product/123"), match_message: "Unique match found", - match_type: "unique", + matchType: "unique", request_ip_version: "ipv4", utm_medium: "social", utm_source: "facebook" @@ -101,7 +101,7 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { let noneResponse = PostInstallLinkSearchResponse( deep_link_id: nil, match_message: "No match found", - match_type: "none", + matchType: "none", request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -112,7 +112,7 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { let ambiguousResponse = PostInstallLinkSearchResponse( deep_link_id: URL(string: "https://example.com/default"), match_message: "Ambiguous match", - match_type: "heuristics", + matchType: "heuristics", request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -123,7 +123,7 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { let heuristicsResponse = PostInstallLinkSearchResponse( deep_link_id: URL(string: "https://example.com/default"), match_message: "Heuristics match", - match_type: "ambiguous", + matchType: "ambiguous", request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -134,7 +134,7 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { let unknownResponse = PostInstallLinkSearchResponse( deep_link_id: nil, match_message: "Unknown", - match_type: "other", + matchType: "other", request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -310,7 +310,7 @@ func testAPIProviderWithMockNetwork() async throws { { "deep_link_id": "https://example.com/product/123", "match_message": "Test match", - "match_type": "unique", + "matchType": "unique", "request_ip_version": "ipv4", "utm_medium": null, "utm_source": null @@ -350,7 +350,7 @@ func testAPIProviderWithMockNetwork() async throws { let response = try await apiProvider.sendFingerprint(testFingerprint) #expect(response.deep_link_id?.absoluteString == "https://example.com/product/123") - #expect(response.match_type == "unique") + #expect(response.matchType == "unique") #expect(response.matchType == TracebackSDK.MatchType.unique) } @@ -418,12 +418,12 @@ func testResultObjectCreation() throws { let result = TracebackSDK.Result( url: testURL, - match_type: .unique, + matchType: .unique, analytics: testAnalytics ) #expect(result.url == testURL) - #expect(result.match_type == TracebackSDK.MatchType.unique) + #expect(result.matchType == TracebackSDK.MatchType.unique) #expect(result.analytics.count == 1) if case .postInstallDetected(let analyticsURL) = result.analytics.first { @@ -438,6 +438,6 @@ func testEmptyResult() throws { let emptyResult = TracebackSDK.Result.empty #expect(emptyResult.url == nil) - #expect(emptyResult.match_type == TracebackSDK.MatchType.none) + #expect(emptyResult.matchType == TracebackSDK.MatchType.none) #expect(emptyResult.analytics.isEmpty) } diff --git a/Tests/TracebackTests/NetworkTests.swift b/Tests/TracebackTests/NetworkTests.swift index 5f596e1..6e62348 100644 --- a/Tests/TracebackTests/NetworkTests.swift +++ b/Tests/TracebackTests/NetworkTests.swift @@ -273,7 +273,7 @@ func testNetworkConfigurationWithAPIProvider() async throws { { "deep_link_id": "https://example.com/test", "match_message": "Success", - "match_type": "unique", + "matchType": "unique", "request_ip_version": "ipv4", "utm_medium": null, "utm_source": null diff --git a/Tests/TracebackTests/TracebackTests.swift b/Tests/TracebackTests/TracebackTests.swift index 7b4063b..1299718 100644 --- a/Tests/TracebackTests/TracebackTests.swift +++ b/Tests/TracebackTests/TracebackTests.swift @@ -21,6 +21,7 @@ func checkCreateFingerPrint() async throws { let createdFingerPrint = await createDeviceFingerprint( system: system, linkFromClipboard: link, + linkFromIntent: nil, webviewInfo: WebViewInfo( language: localeFromWebView, appVersion: appVersionFromWebView @@ -32,6 +33,7 @@ func checkCreateFingerPrint() async throws { osVersion: system.osVersion, sdkVersion: system.sdkVersion, uniqueMatchLinkToCheck: link, + intentLink: nil, device: .init( deviceModelName: system.deviceModelName, languageCode: "es-ES", From ccea2dcdb3956c2d9acc033762ee8f808d86e2c5 Mon Sep 17 00:00:00 2001 From: Sergi Hernanz Date: Sun, 26 Oct 2025 01:50:27 +0200 Subject: [PATCH 02/10] Fixed v1_get_campaign url --- Sources/Traceback/Private/API/APIProvider.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Traceback/Private/API/APIProvider.swift b/Sources/Traceback/Private/API/APIProvider.swift index 07e1aed..daac5d6 100644 --- a/Sources/Traceback/Private/API/APIProvider.swift +++ b/Sources/Traceback/Private/API/APIProvider.swift @@ -36,7 +36,7 @@ struct APIProvider: Sendable { func getCampaignLink(from url: String, isFirstCampaignOpen: Bool) async throws -> CampaignResponse { var urlComponents = URLComponents( - url: config.host.appendingPathComponent("v1_getCampaign", isDirectory: false), + url: config.host.appendingPathComponent("v1_get_campaign", isDirectory: false), resolvingAgainstBaseURL: false ) From 0b5518f5c6b922b0e52491792576477a4523c133 Mon Sep 17 00:00:00 2001 From: Sergi Hernanz Date: Tue, 28 Oct 2025 17:41:45 +0100 Subject: [PATCH 03/10] Created actor fpr waiting for url --- .../Traceback/Private/TracebackSDKImpl.swift | 32 ++------------- Sources/Traceback/Private/ValueWaiter.swift | 39 +++++++++++++++++++ Tests/TracebackTests/NetworkTests.swift | 1 + 3 files changed, 43 insertions(+), 29 deletions(-) create mode 100644 Sources/Traceback/Private/ValueWaiter.swift diff --git a/Sources/Traceback/Private/TracebackSDKImpl.swift b/Sources/Traceback/Private/TracebackSDKImpl.swift index c1a3c61..a9218a9 100644 --- a/Sources/Traceback/Private/TracebackSDKImpl.swift +++ b/Sources/Traceback/Private/TracebackSDKImpl.swift @@ -20,8 +20,7 @@ final class TracebackSDKImpl { private let config: TracebackConfiguration private let logger: Logger private let campaignTracker: CampaignTracker - - private var universalLinkContinuation: CheckedContinuation? + private let linkDetectionActor = ValueWaiter() init(config: TracebackConfiguration, logger: Logger, campaignTracker: CampaignTracker) { self.config = config @@ -39,7 +38,7 @@ final class TracebackSDKImpl { } logger.debug("Waiting for universal link") - let linkFromIntent = await waitForUniversalLink(timeout: 0.5) + let linkFromIntent = await linkDetectionActor.waitForValue(timeout: 0.5) logger.debug("Got universal link: \(linkFromIntent?.absoluteString ?? "none")") logger.info("Checking for post-install link") @@ -125,37 +124,12 @@ final class TracebackSDKImpl { } } - private func waitForUniversalLink(timeout: TimeInterval) async -> URL? { - // Create a cancellation handle - let timeoutTask = Task { - try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - return nil as URL? - } - - // Wait for whichever finishes first - let result: URL? = await withCheckedContinuation { continuation in - // Store continuation so it can be resumed externally - self.universalLinkContinuation = continuation - - // Also start the timeout watcher - Task { - let url = await timeoutTask.value - continuation.resume(returning: url) - } - } - - // Whichever won, cancel the other - timeoutTask.cancel() - - return result - } - func getCampaignLink(from url: URL) async -> TracebackSDK.Result { do { // 1. Check if first run, if not save link and continue guard UserDefaults.standard.bool(forKey: userDefaultsExistingRunKey) else { logger.info("Do not get campaign links on first run, do it via postInstallSearch") - universalLinkContinuation?.resume(returning: url) + await linkDetectionActor.provideValue(url) return .empty } diff --git a/Sources/Traceback/Private/ValueWaiter.swift b/Sources/Traceback/Private/ValueWaiter.swift new file mode 100644 index 0000000..8bd6a59 --- /dev/null +++ b/Sources/Traceback/Private/ValueWaiter.swift @@ -0,0 +1,39 @@ +import Foundation + +actor ValueWaiter { + private var pendingValue: T? + private var continuation: CheckedContinuation? + private var alreadyCalled = false + + func waitForValue(timeout: TimeInterval) async -> T? { + assert(!alreadyCalled) + alreadyCalled = true + if let pendingValue = self.pendingValue { + self.pendingValue = nil + return pendingValue + } + return await withCheckedContinuation { continuation in + self.continuation = continuation + Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + self.timeoutExpired() + } + } + } + + func provideValue(_ value: T) { + if let continuation = self.continuation { + self.continuation = nil + continuation.resume(returning: value) + } else { + self.pendingValue = value + } + } + + private func timeoutExpired() { + if let continuation = self.continuation { + self.continuation = nil + continuation.resume(returning: nil) + } + } +} diff --git a/Tests/TracebackTests/NetworkTests.swift b/Tests/TracebackTests/NetworkTests.swift index 6e62348..f391cc9 100644 --- a/Tests/TracebackTests/NetworkTests.swift +++ b/Tests/TracebackTests/NetworkTests.swift @@ -299,6 +299,7 @@ func testNetworkConfigurationWithAPIProvider() async throws { osVersion: "18.0", sdkVersion: "1.0.0", uniqueMatchLinkToCheck: nil, + intentLink: nil, device: DeviceFingerprint.DeviceInfo( deviceModelName: "iPhone15,2", languageCode: "en-US", From efb6e8f92d2ae6780f40b3c27b024249ad17e030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20S=C3=A1nchez?= Date: Wed, 29 Oct 2025 13:20:58 +0100 Subject: [PATCH 04/10] Add intent support --- .../API/PostInstallLinkSearchResponse.swift | 2 + .../Private/DependenciesImpl/LoggerImpl.swift | 6 +-- .../Traceback/Private/TracebackSDKImpl.swift | 48 +++++++++---------- .../Traceback/TracebackConfiguration.swift | 2 +- Sources/Traceback/TracebackSDK.swift | 18 +++++-- 5 files changed, 42 insertions(+), 34 deletions(-) diff --git a/Sources/Traceback/Private/API/PostInstallLinkSearchResponse.swift b/Sources/Traceback/Private/API/PostInstallLinkSearchResponse.swift index fe100bd..b042bb5 100644 --- a/Sources/Traceback/Private/API/PostInstallLinkSearchResponse.swift +++ b/Sources/Traceback/Private/API/PostInstallLinkSearchResponse.swift @@ -28,6 +28,8 @@ extension PostInstallLinkSearchResponse { return .ambiguous case "none": return .none + case "intent": + return .intent default: return .unknown } diff --git a/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift b/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift index cf483cd..cfac066 100644 --- a/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift +++ b/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift @@ -18,14 +18,14 @@ extension Logger { return Logger( info: { message in guard level == .info || level == .debug else { return } - logger.info("[Traceback] \(message())") + logger.info("[Traceback] \(message(), privacy: .public)") }, debug: { message in guard level == .debug else { return } - logger.debug("[Traceback] \(message())") + logger.debug("[Traceback] \(message(), privacy: .public)") }, error: { message in - logger.error("[Traceback] \(message())") + logger.error("[Traceback] \(message(), privacy: .public)") } ) } diff --git a/Sources/Traceback/Private/TracebackSDKImpl.swift b/Sources/Traceback/Private/TracebackSDKImpl.swift index a9218a9..c3ddc5a 100644 --- a/Sources/Traceback/Private/TracebackSDKImpl.swift +++ b/Sources/Traceback/Private/TracebackSDKImpl.swift @@ -12,7 +12,7 @@ private let userDefaultsExistingRunKey = "traceback_existingRun" extension TracebackSDK.Result { static var empty: Self { - TracebackSDK.Result(url: nil, campaign: nil, matchType: .none, analytics: []) + TracebackSDK.Result(url: nil, matchType: .none, analytics: []) } } @@ -80,6 +80,7 @@ final class TracebackSDKImpl { let response = try await api.sendFingerprint(fingerprint) logger.info("Server responded with match type: \(response.matchType)") + logger.debug("Server responded deep link: \(response)") // 5. Save checks locally UserDefaults.standard.set(true, forKey: userDefaultsExistingRunKey) @@ -91,23 +92,9 @@ final class TracebackSDKImpl { logger.info("Campaign \(campaign) seen for first time") } - // TODO: remove this if backend sends final deeplink - if - let longLink = response.deep_link_id, - let deeplink = try? extractLink(from: longLink) - { - return TracebackSDK.Result( - url: deeplink, - campaign: response.match_campaign, - matchType: response.matchType, - analytics: response.deep_link_id.map { [.postInstallDetected($0)] } ?? [] - ) - } - // 6. Return what we have found return TracebackSDK.Result( url: response.deep_link_id, - campaign: response.match_campaign, matchType: response.matchType, analytics: response.deep_link_id.map { [.postInstallDetected($0)] } ?? [] ) @@ -115,7 +102,6 @@ final class TracebackSDKImpl { logger.error("Failed to detect post-install link: \(error.localizedDescription)") return TracebackSDK.Result( url: nil, - campaign: nil, matchType: .none, analytics: [ .postInstallError(error) @@ -126,9 +112,11 @@ final class TracebackSDKImpl { func getCampaignLink(from url: URL) async -> TracebackSDK.Result { do { + logger.info("Get campaign link") + // 1. Check if first run, if not save link and continue guard UserDefaults.standard.bool(forKey: userDefaultsExistingRunKey) else { - logger.info("Do not get campaign links on first run, do it via postInstallSearch") + logger.info("Do not get campaign link on first run, do it via postInstallSearch") await linkDetectionActor.provideValue(url) return .empty } @@ -138,19 +126,16 @@ final class TracebackSDKImpl { // 3. If no campaign, process locally guard let campaign else { + logger.info("The link does not have a campaign, treat locally as intent") let deeplink = try? extractLink(from: url) return TracebackSDK.Result( url: deeplink, - campaign: campaign, - matchType: .unique, + matchType: .intent, analytics: [] ) } - let isFirstCampaignOpen = campaignTracker.isFirstTimeSeen(campaign) - logger.info("Campaign \(campaign) first open \(isFirstCampaignOpen)") - // 3. Get campaign resolution from backend let api = APIProvider( config: NetworkConfiguration( @@ -159,6 +144,11 @@ final class TracebackSDKImpl { network: Network.live ) + logger.info("Getting campaign deeplink remotely for campaign \(campaign)") + + let isFirstCampaignOpen = campaignTracker.isFirstTimeSeen(campaign) + logger.debug("Campaign \(campaign) first open \(isFirstCampaignOpen)") + let response = try await api.getCampaignLink(from: url.absoluteString, isFirstCampaignOpen: isFirstCampaignOpen) logger.info("Server responded with link: \(String(describing: response.result))") @@ -170,16 +160,15 @@ final class TracebackSDKImpl { return TracebackSDK.Result( url: deeplink, - campaign: campaign, - matchType: .unique, + matchType: .intent, analytics: [ .campaignResolved(deeplink) ] ) } catch { + logger.error("Failed to resolve campaign link: \(error.localizedDescription)") return TracebackSDK.Result( url: nil, - campaign: nil, matchType: .none, analytics: [ .campaignError(error) @@ -210,6 +199,7 @@ final class TracebackSDKImpl { return nil } + /// Parses the campaign from a Traceback URL, which is the path of the Traceback URL private func extractCampaign(from url: URL) -> String? { let path = url.path if path.count > 1 { @@ -218,6 +208,14 @@ final class TracebackSDKImpl { return nil } + /// Determines whether a URL should be processed by Traceback or not based on the configured domains + func isTracebackURL(_ url: URL) -> Bool { + guard let host = url.host else { return false } + + return host == config.mainAssociatedHost.host || + (config.associatedHosts?.contains(where: { $0.host == host }) ?? false) + } + @MainActor static func performDiagnostics( config: TracebackConfiguration, diff --git a/Sources/Traceback/TracebackConfiguration.swift b/Sources/Traceback/TracebackConfiguration.swift index 6e76c7f..b4bfbd2 100644 --- a/Sources/Traceback/TracebackConfiguration.swift +++ b/Sources/Traceback/TracebackConfiguration.swift @@ -8,7 +8,7 @@ import Foundation /// Events returned by the sdk methods so they can be reported to your preferred analytics platform -public enum TracebackAnalyticsEvent { +public enum TracebackAnalyticsEvent: Sendable { /// A post-installation content url has been detected case postInstallDetected(URL) /// A post-installation content url failure diff --git a/Sources/Traceback/TracebackSDK.swift b/Sources/Traceback/TracebackSDK.swift index dbd5e95..8771e15 100644 --- a/Sources/Traceback/TracebackSDK.swift +++ b/Sources/Traceback/TracebackSDK.swift @@ -12,7 +12,7 @@ import Foundation /// public struct TracebackSDK { - static let sdkVersion = "0.3.0" + static let sdkVersion = "iOS/0.3.1" /// Initialization value passed to TracebackSDK.live public let configuration: TracebackConfiguration @@ -30,6 +30,12 @@ public struct TracebackSDK { /// will search for the content url associated to the opened url. public let campaignSearchLink: (URL) async throws -> TracebackSDK.Result? + /// Validate input URL + /// + /// @Discussion Validates if the domain of the given URL matches any of the + /// associated domains in configuration + public let isTracebackURL: (URL) -> Bool + /// Diagnostics info /// /// @Discussion Call this method at app startup to diagnose your current setup @@ -38,20 +44,19 @@ public struct TracebackSDK { /// /// Match type when searching for a post-install link /// - public enum MatchType { + public enum MatchType: Sendable { case unique /// A unique result returned, given by pasteboard case heuristics /// Heuristics search success case ambiguous /// Heuristics seach success but ambiguous match case none /// No match found + case intent /// A unique result returned, given by link opened by client case unknown /// Not determined match type } /// Result of traceback main methods. Contains the resulting URL and events so analytics can be saved - public struct Result { + public struct Result: Sendable { /// A valid url if the method correctly finds a post install link, or opened url contains a valid deep link public let url: URL? - /// The campaign associated to the inspected URL, if any - public let campaign: String? /// The match type when extracting the post install public let matchType: MatchType /// Analytics to be sent to your preferred analytics platform @@ -86,6 +91,9 @@ public extension TracebackSDK { campaignSearchLink: { url in await implementation.getCampaignLink(from: url) }, + isTracebackURL: { url in + implementation.isTracebackURL(url) + }, performDiagnostics: { Task { @MainActor in var result: DiagnosticsResult? From d57a202398224b394fbaf8c91b6189345b59aaf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Sa=CC=81nchez=20Pardo?= Date: Thu, 30 Oct 2025 08:02:39 +0100 Subject: [PATCH 05/10] Add intent to doc --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e7ea39e..2f7b559 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The diagnostics will categorize issues as: The result object returned by `postInstallSearchLink()` and `extractLinkFromURL()` contains: - `url: URL?` - The extracted deep link URL to navigate to -- `matchType: MatchType` - How the link was detected (`.unique`, `.heuristics`, `.ambiguous`, `.none`) +- `matchType: MatchType` - How the link was detected (`.unique`, `.heuristics`, `.ambiguous`, `.intent`, `.none`) - `analytics: [TracebackAnalyticsEvent]` - Analytics events you can send to your preferred platform ### TracebackConfiguration From bb8917d8ce516cecec054b640e13a4bed696f014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Sa=CC=81nchez=20Pardo?= Date: Thu, 30 Oct 2025 08:02:58 +0100 Subject: [PATCH 06/10] Some work on tests --- Tests/TracebackTests/AdditionalTests.swift | 27 ++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/Tests/TracebackTests/AdditionalTests.swift b/Tests/TracebackTests/AdditionalTests.swift index bf0de3a..1b26a33 100644 --- a/Tests/TracebackTests/AdditionalTests.swift +++ b/Tests/TracebackTests/AdditionalTests.swift @@ -90,7 +90,8 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { let uniqueResponse = PostInstallLinkSearchResponse( deep_link_id: URL(string: "https://example.com/product/123"), match_message: "Unique match found", - matchType: "unique", + match_type: "unique", + match_campaign: nil, request_ip_version: "ipv4", utm_medium: "social", utm_source: "facebook" @@ -101,7 +102,8 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { let noneResponse = PostInstallLinkSearchResponse( deep_link_id: nil, match_message: "No match found", - matchType: "none", + match_type: "none", + match_campaign: nil, request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -112,7 +114,8 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { let ambiguousResponse = PostInstallLinkSearchResponse( deep_link_id: URL(string: "https://example.com/default"), match_message: "Ambiguous match", - matchType: "heuristics", + match_type: "heuristics", + match_campaign: nil, request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -123,7 +126,8 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { let heuristicsResponse = PostInstallLinkSearchResponse( deep_link_id: URL(string: "https://example.com/default"), match_message: "Heuristics match", - matchType: "ambiguous", + match_type: "ambiguous", + match_campaign: nil, request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -134,7 +138,8 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { let unknownResponse = PostInstallLinkSearchResponse( deep_link_id: nil, match_message: "Unknown", - matchType: "other", + match_type: "other", + match_campaign: nil, request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -213,7 +218,7 @@ func testAnalyticsEvents() throws { switch detectedEvent { case .postInstallDetected(let url): #expect(url == testURL) - case .postInstallError: + default: #expect(Bool(false), "Expected postInstallDetected event") } @@ -223,10 +228,10 @@ func testAnalyticsEvents() throws { let errorEvent = TracebackAnalyticsEvent.postInstallError(error) switch errorEvent { - case .postInstallDetected: - #expect(Bool(false), "Expected postInstallError event") case .postInstallError(let receivedError): #expect(receivedError is TestError) + default: + #expect(Bool(false), "Expected postInstallError event") } } @@ -285,6 +290,7 @@ func testDeviceFingerprintEquality() throws { osVersion: "18.0", sdkVersion: "1.0.0", uniqueMatchLinkToCheck: nil, + intentLink: nil, device: deviceInfo1 ) @@ -294,6 +300,7 @@ func testDeviceFingerprintEquality() throws { osVersion: "18.0", sdkVersion: "1.0.0", uniqueMatchLinkToCheck: nil, + intentLink: nil, device: deviceInfo2 ) @@ -335,6 +342,7 @@ func testAPIProviderWithMockNetwork() async throws { osVersion: "18.0", sdkVersion: "1.0.0", uniqueMatchLinkToCheck: URL(string: "https://test.com/link"), + intentLink: nil, device: DeviceFingerprint.DeviceInfo( deviceModelName: "iPhone15,2", languageCode: "en-US", @@ -350,7 +358,7 @@ func testAPIProviderWithMockNetwork() async throws { let response = try await apiProvider.sendFingerprint(testFingerprint) #expect(response.deep_link_id?.absoluteString == "https://example.com/product/123") - #expect(response.matchType == "unique") + #expect(response.match_type == "unique") #expect(response.matchType == TracebackSDK.MatchType.unique) } @@ -369,6 +377,7 @@ func testAPIProviderNetworkError() async throws { osVersion: "18.0", sdkVersion: "1.0.0", uniqueMatchLinkToCheck: nil, + intentLink: nil, device: DeviceFingerprint.DeviceInfo( deviceModelName: "iPhone15,2", languageCode: "en-US", From ea6baf209c8499759b737d3cab40a00b49b6cdff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20S=C3=A1nchez?= Date: Thu, 30 Oct 2025 09:14:47 +0100 Subject: [PATCH 07/10] Recover tests. Extract tests commented. --- .../Traceback/Private/TracebackSDKImpl.swift | 2 +- Tests/TracebackTests/AdditionalTests.swift | 152 +++++++++++------- Tests/TracebackTests/NetworkTests.swift | 3 +- 3 files changed, 96 insertions(+), 61 deletions(-) diff --git a/Sources/Traceback/Private/TracebackSDKImpl.swift b/Sources/Traceback/Private/TracebackSDKImpl.swift index c3ddc5a..5ea6ef0 100644 --- a/Sources/Traceback/Private/TracebackSDKImpl.swift +++ b/Sources/Traceback/Private/TracebackSDKImpl.swift @@ -79,7 +79,7 @@ final class TracebackSDKImpl { ) let response = try await api.sendFingerprint(fingerprint) - logger.info("Server responded with match type: \(response.matchType)") + logger.info("Server responded with match type: \(response.match_type)") logger.debug("Server responded deep link: \(response)") // 5. Save checks locally diff --git a/Tests/TracebackTests/AdditionalTests.swift b/Tests/TracebackTests/AdditionalTests.swift index 1b26a33..f1937eb 100644 --- a/Tests/TracebackTests/AdditionalTests.swift +++ b/Tests/TracebackTests/AdditionalTests.swift @@ -37,50 +37,50 @@ func testTracebackConfigurationDefaults() throws { // MARK: - URL Extraction Tests -@Test -func testExtractLinkFromURL() throws { - let config = TracebackConfiguration( - mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! - ) - let sdk = TracebackSDK.live(config: config) - - // Test valid URL with link parameter - let urlWithLink = URL(string: "https://example.com?link=https%3A%2F%2Fmyapp.com%2Fproduct%2F123")! - let result = try sdk.extractLinkFromURL(urlWithLink) - - #expect(result?.url?.absoluteString == "https://myapp.com/product/123") - #expect(result?.matchType == TracebackSDK.MatchType.unknown) -} - -@Test -func testExtractLinkFromURLWithoutLinkParameter() throws { - let config = TracebackConfiguration( - mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! - ) - let sdk = TracebackSDK.live(config: config) - - // Test URL without link parameter - let urlWithoutLink = URL(string: "https://example.com?other=value")! - let result = try sdk.extractLinkFromURL(urlWithoutLink) - - #expect(result?.url == nil) - #expect(result?.matchType == TracebackSDK.MatchType.unknown) -} - -@Test -func testExtractLinkFromURLWithMultipleQueryParams() throws { - let config = TracebackConfiguration( - mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! - ) - let sdk = TracebackSDK.live(config: config) - - // Test URL with multiple query parameters including link - let complexURL = URL(string: "https://example.com?utm_source=email&link=https%3A%2F%2Fmyapp.com%2Fshare%2Fabc&utm_campaign=test")! - let result = try sdk.extractLinkFromURL(complexURL) - - #expect(result?.url?.absoluteString == "https://myapp.com/share/abc") - #expect(result?.matchType == TracebackSDK.MatchType.unknown) -} +//@Test +//func testExtractLinkFromURL() throws { +// let config = TracebackConfiguration( +// mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! +// ) +// let sdk = TracebackSDK.live(config: config) +// +// // Test valid URL with link parameter +// let urlWithLink = URL(string: "https://example.com?link=https%3A%2F%2Fmyapp.com%2Fproduct%2F123")! +// let result = try sdk.extractLinkFromURL(urlWithLink) +// +// #expect(result?.url?.absoluteString == "https://myapp.com/product/123") +// #expect(result?.matchType == TracebackSDK.MatchType.unknown) +//} +// +//@Test +//func testExtractLinkFromURLWithoutLinkParameter() throws { +// let config = TracebackConfiguration( +// mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! +// ) +// let sdk = TracebackSDK.live(config: config) +// +// // Test URL without link parameter +// let urlWithoutLink = URL(string: "https://example.com?other=value")! +// let result = try sdk.extractLinkFromURL(urlWithoutLink) +// +// #expect(result?.url == nil) +// #expect(result?.matchType == TracebackSDK.MatchType.unknown) +//} +// +//@Test +//func testExtractLinkFromURLWithMultipleQueryParams() throws { +// let config = TracebackConfiguration( +// mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! +// ) +// let sdk = TracebackSDK.live(config: config) +// +// // Test URL with multiple query parameters including link +// let complexURL = URL(string: "https://example.com?utm_source=email&link=https%3A%2F%2Fmyapp.com%2Fshare%2Fabc&utm_campaign=test")! +// let result = try sdk.extractLinkFromURL(complexURL) +// +// #expect(result?.url?.absoluteString == "https://myapp.com/share/abc") +// #expect(result?.matchType == TracebackSDK.MatchType.unknown) +//} // MARK: - Response Model Tests @@ -133,6 +133,18 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { utm_source: nil ) #expect(heuristicsResponse.matchType == TracebackSDK.MatchType.ambiguous) + + // Test intent match type + let intentResponse = PostInstallLinkSearchResponse( + deep_link_id: URL(string: "https://example.com/default"), + match_message: "Intent match", + match_type: "intent", + match_campaign: nil, + request_ip_version: "ipv4", + utm_medium: nil, + utm_source: nil + ) + #expect(intentResponse.matchType == TracebackSDK.MatchType.intent) // Test unknown match type let unknownResponse = PostInstallLinkSearchResponse( @@ -147,6 +159,27 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { #expect(unknownResponse.matchType == TracebackSDK.MatchType.unknown) } +@Test +func testCampaignLinkSearchResponse() throws { + + // Test response with valid URL + let response = CampaignResponse( + result: URL(string: "https://example.com/default"), + error: nil + ) + #expect(response.result?.absoluteString == "https://example.com/default") + #expect(response.error == nil) + + // Test response with error + let errorResponse = CampaignResponse( + result: nil, + error: "Invalid link" + ) + #expect(errorResponse.result == nil) + #expect(errorResponse.error == "Invalid link") + +} + // MARK: - Network Error Tests @Test @@ -317,7 +350,8 @@ func testAPIProviderWithMockNetwork() async throws { { "deep_link_id": "https://example.com/product/123", "match_message": "Test match", - "matchType": "unique", + "match_type": "unique", + "match_campaign": null, "request_ip_version": "ipv4", "utm_medium": null, "utm_source": null @@ -403,20 +437,20 @@ func testAPIProviderNetworkError() async throws { // MARK: - URL Components Edge Cases -@Test -func testExtractLinkFromURLWithMalformedEncoding() throws { - let config = TracebackConfiguration( - mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! - ) - let sdk = TracebackSDK.live(config: config) - - // Test URL with improperly encoded link parameter - let malformedURL = URL(string: "https://example.com?link=https://myapp.com/product/123")! // Not URL encoded - let result = try sdk.extractLinkFromURL(malformedURL) - - // Should still extract the link even if not properly encoded - #expect(result?.url?.absoluteString == "https://myapp.com/product/123") -} +//@Test +//func testExtractLinkFromURLWithMalformedEncoding() throws { +// let config = TracebackConfiguration( +// mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! +// ) +// let sdk = TracebackSDK.live(config: config) +// +// // Test URL with improperly encoded link parameter +// let malformedURL = URL(string: "https://example.com?link=https://myapp.com/product/123")! // Not URL encoded +// let result = try sdk.extractLinkFromURL(malformedURL) +// +// // Should still extract the link even if not properly encoded +// #expect(result?.url?.absoluteString == "https://myapp.com/product/123") +//} // MARK: - Result Object Tests diff --git a/Tests/TracebackTests/NetworkTests.swift b/Tests/TracebackTests/NetworkTests.swift index f391cc9..1821a13 100644 --- a/Tests/TracebackTests/NetworkTests.swift +++ b/Tests/TracebackTests/NetworkTests.swift @@ -273,7 +273,8 @@ func testNetworkConfigurationWithAPIProvider() async throws { { "deep_link_id": "https://example.com/test", "match_message": "Success", - "matchType": "unique", + "match_type": "unique", + "match_campaign": "summer_sale", "request_ip_version": "ipv4", "utm_medium": null, "utm_source": null From 44aaa74126f4a3ee9ff8c8e3212974316d706859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20S=C3=A1nchez?= Date: Thu, 30 Oct 2025 15:58:57 +0100 Subject: [PATCH 08/10] Finalize tests. Campaign still missing --- .../Traceback/Private/CampaignTracker.swift | 6 +- Tests/TracebackTests/AdditionalTests.swift | 64 ----------------- .../TracebackTests/CampaignTrackerTests.swift | 70 +++++++++++++++++++ 3 files changed, 75 insertions(+), 65 deletions(-) create mode 100644 Tests/TracebackTests/CampaignTrackerTests.swift diff --git a/Sources/Traceback/Private/CampaignTracker.swift b/Sources/Traceback/Private/CampaignTracker.swift index 0bf19df..962b8a9 100644 --- a/Sources/Traceback/Private/CampaignTracker.swift +++ b/Sources/Traceback/Private/CampaignTracker.swift @@ -8,8 +8,12 @@ import Foundation class CampaignTracker { - private let userDefaults = UserDefaults.standard + private let userDefaults: UserDefaults private let seenCampaignsKey = "seenCampaigns" + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } // Get all seen campaigns private func getSeenCampaigns() -> Set { diff --git a/Tests/TracebackTests/AdditionalTests.swift b/Tests/TracebackTests/AdditionalTests.swift index f1937eb..82c28a1 100644 --- a/Tests/TracebackTests/AdditionalTests.swift +++ b/Tests/TracebackTests/AdditionalTests.swift @@ -35,53 +35,6 @@ func testTracebackConfigurationDefaults() throws { #expect(config.logLevel == .info) } -// MARK: - URL Extraction Tests - -//@Test -//func testExtractLinkFromURL() throws { -// let config = TracebackConfiguration( -// mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! -// ) -// let sdk = TracebackSDK.live(config: config) -// -// // Test valid URL with link parameter -// let urlWithLink = URL(string: "https://example.com?link=https%3A%2F%2Fmyapp.com%2Fproduct%2F123")! -// let result = try sdk.extractLinkFromURL(urlWithLink) -// -// #expect(result?.url?.absoluteString == "https://myapp.com/product/123") -// #expect(result?.matchType == TracebackSDK.MatchType.unknown) -//} -// -//@Test -//func testExtractLinkFromURLWithoutLinkParameter() throws { -// let config = TracebackConfiguration( -// mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! -// ) -// let sdk = TracebackSDK.live(config: config) -// -// // Test URL without link parameter -// let urlWithoutLink = URL(string: "https://example.com?other=value")! -// let result = try sdk.extractLinkFromURL(urlWithoutLink) -// -// #expect(result?.url == nil) -// #expect(result?.matchType == TracebackSDK.MatchType.unknown) -//} -// -//@Test -//func testExtractLinkFromURLWithMultipleQueryParams() throws { -// let config = TracebackConfiguration( -// mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! -// ) -// let sdk = TracebackSDK.live(config: config) -// -// // Test URL with multiple query parameters including link -// let complexURL = URL(string: "https://example.com?utm_source=email&link=https%3A%2F%2Fmyapp.com%2Fshare%2Fabc&utm_campaign=test")! -// let result = try sdk.extractLinkFromURL(complexURL) -// -// #expect(result?.url?.absoluteString == "https://myapp.com/share/abc") -// #expect(result?.matchType == TracebackSDK.MatchType.unknown) -//} - // MARK: - Response Model Tests @Test @@ -435,23 +388,6 @@ func testAPIProviderNetworkError() async throws { } } -// MARK: - URL Components Edge Cases - -//@Test -//func testExtractLinkFromURLWithMalformedEncoding() throws { -// let config = TracebackConfiguration( -// mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! -// ) -// let sdk = TracebackSDK.live(config: config) -// -// // Test URL with improperly encoded link parameter -// let malformedURL = URL(string: "https://example.com?link=https://myapp.com/product/123")! // Not URL encoded -// let result = try sdk.extractLinkFromURL(malformedURL) -// -// // Should still extract the link even if not properly encoded -// #expect(result?.url?.absoluteString == "https://myapp.com/product/123") -//} - // MARK: - Result Object Tests @Test diff --git a/Tests/TracebackTests/CampaignTrackerTests.swift b/Tests/TracebackTests/CampaignTrackerTests.swift new file mode 100644 index 0000000..d3f09f8 --- /dev/null +++ b/Tests/TracebackTests/CampaignTrackerTests.swift @@ -0,0 +1,70 @@ +import Testing +import Foundation +@testable import Traceback + +@Test +func testCampaignTrackerBasicFlow() throws { + let suiteName = "test.campaignTracker.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw NSError(domain: "UserDefaultsInit", code: 1) + } + // Ensure a clean slate + defaults.removePersistentDomain(forName: suiteName) + + let tracker = CampaignTracker(userDefaults: defaults) + + // fresh state + #expect(tracker.hasSeenCampaign("campaign1") == false) + #expect(tracker.isFirstTimeSeen("campaign1") == true) + #expect(tracker.hasSeenCampaign("campaign1") == true) + #expect(tracker.isFirstTimeSeen("campaign1") == false) + + // mark another campaign + tracker.markCampaignAsSeen("campaign2") + #expect(tracker.hasSeenCampaign("campaign2") == true) + + // clear + tracker.clearSeenCampaigns() + #expect(tracker.hasSeenCampaign("campaign1") == false) + #expect(tracker.hasSeenCampaign("campaign2") == false) + + // cleanup + defaults.removePersistentDomain(forName: suiteName) +} + +@Test +func testPersistenceAcrossInstances() throws { + let suiteName = "test.campaignTracker.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw NSError(domain: "UserDefaultsInit", code: 1) + } + defaults.removePersistentDomain(forName: suiteName) + + let tracker1 = CampaignTracker(userDefaults: defaults) + #expect(tracker1.hasSeenCampaign("persist") == false) + tracker1.markCampaignAsSeen("persist") + #expect(tracker1.hasSeenCampaign("persist") == true) + + // Create a new tracker backed by the same suite to ensure persistence + let tracker2 = CampaignTracker(userDefaults: defaults) + #expect(tracker2.hasSeenCampaign("persist") == true) + + // cleanup + defaults.removePersistentDomain(forName: suiteName) +} + +@Test +func testEmptyCampaignString() throws { + let suiteName = "test.campaignTracker.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw NSError(domain: "UserDefaultsInit", code: 1) + } + defaults.removePersistentDomain(forName: suiteName) + + let tracker = CampaignTracker(userDefaults: defaults) + + #expect(tracker.isFirstTimeSeen("") == true) + #expect(tracker.isFirstTimeSeen("") == false) + + defaults.removePersistentDomain(forName: suiteName) +} From 91dbfad2523465ee1e0901fd0bf5d36f75e7d34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Sa=CC=81nchez=20Pardo?= Date: Thu, 30 Oct 2025 16:15:31 +0100 Subject: [PATCH 09/10] Add tests for campaign API provider --- Tests/TracebackTests/AdditionalTests.swift | 39 +++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/Tests/TracebackTests/AdditionalTests.swift b/Tests/TracebackTests/AdditionalTests.swift index 82c28a1..a936bc1 100644 --- a/Tests/TracebackTests/AdditionalTests.swift +++ b/Tests/TracebackTests/AdditionalTests.swift @@ -274,7 +274,7 @@ func testDeviceFingerprintEquality() throws { appInstallationTime: 1234567890, bundleId: "com.example.app", osVersion: "18.0", - sdkVersion: "1.0.0", + sdkVersion: TracebackSDK.sdkVersion, uniqueMatchLinkToCheck: nil, intentLink: nil, device: deviceInfo1 @@ -284,7 +284,7 @@ func testDeviceFingerprintEquality() throws { appInstallationTime: 1234567890, bundleId: "com.example.app", osVersion: "18.0", - sdkVersion: "1.0.0", + sdkVersion: TracebackSDK.sdkVersion, uniqueMatchLinkToCheck: nil, intentLink: nil, device: deviceInfo2 @@ -296,7 +296,7 @@ func testDeviceFingerprintEquality() throws { // MARK: - Integration Tests with Mock Network @Test -func testAPIProviderWithMockNetwork() async throws { +func testPostInstallSearchAPIProviderWithMockNetwork() async throws { let mockNetwork = Network { request in // Create JSON manually since PostInstallLinkSearchResponse is only Decodable let jsonString = """ @@ -327,7 +327,7 @@ func testAPIProviderWithMockNetwork() async throws { appInstallationTime: 1234567890, bundleId: "com.test.app", osVersion: "18.0", - sdkVersion: "1.0.0", + sdkVersion: TracebackSDK.sdkVersion, uniqueMatchLinkToCheck: URL(string: "https://test.com/link"), intentLink: nil, device: DeviceFingerprint.DeviceInfo( @@ -349,6 +349,35 @@ func testAPIProviderWithMockNetwork() async throws { #expect(response.matchType == TracebackSDK.MatchType.unique) } +@Test +func testCampaignSearchAPIProviderWithMockNetwork() async throws { + let mockNetwork = Network { request in + // Create JSON manually since CampaignResponse is only Decodable + let jsonString = """ + { + "result": "https://example.com/product/123", + "error": null, + } + """ + let jsonData = jsonString.data(using: .utf8)! + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (jsonData, response) + } + + let config = NetworkConfiguration(host: URL(string: "https://test.firebaseapp.com")!) + let apiProvider = APIProvider(config: config, network: mockNetwork) + + let response = try await apiProvider.getCampaignLink(from: "https://test.firebaseapp.com/product", isFirstCampaignOpen: false) + + #expect(response.result?.absoluteString == "https://example.com/product/123") + #expect(response.error == nil) +} + @Test func testAPIProviderNetworkError() async throws { let mockNetwork = Network { request in @@ -362,7 +391,7 @@ func testAPIProviderNetworkError() async throws { appInstallationTime: 1234567890, bundleId: "com.test.app", osVersion: "18.0", - sdkVersion: "1.0.0", + sdkVersion: TracebackSDK.sdkVersion, uniqueMatchLinkToCheck: nil, intentLink: nil, device: DeviceFingerprint.DeviceInfo( From 4290d8a86fda75d6141223f90560ece5dd7674d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Sa=CC=81nchez=20Pardo?= Date: Thu, 30 Oct 2025 17:29:13 +0100 Subject: [PATCH 10/10] PR adjustments --- Sources/Traceback/Private/CampaignTracker.swift | 2 +- Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift | 6 +++--- Sources/Traceback/Private/TracebackSDKImpl.swift | 2 +- Sources/Traceback/Private/ValueWaiter.swift | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/Traceback/Private/CampaignTracker.swift b/Sources/Traceback/Private/CampaignTracker.swift index 962b8a9..ac519bb 100644 --- a/Sources/Traceback/Private/CampaignTracker.swift +++ b/Sources/Traceback/Private/CampaignTracker.swift @@ -9,7 +9,7 @@ import Foundation class CampaignTracker { private let userDefaults: UserDefaults - private let seenCampaignsKey = "seenCampaigns" + private let seenCampaignsKey = "_traceback_seen_campaigns" init(userDefaults: UserDefaults = .standard) { self.userDefaults = userDefaults diff --git a/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift b/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift index cfac066..a16537e 100644 --- a/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift +++ b/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift @@ -18,14 +18,14 @@ extension Logger { return Logger( info: { message in guard level == .info || level == .debug else { return } - logger.info("[Traceback] \(message(), privacy: .public)") + logger.info("\(message(), privacy: .public)") }, debug: { message in guard level == .debug else { return } - logger.debug("[Traceback] \(message(), privacy: .public)") + logger.debug("\(message(), privacy: .public)") }, error: { message in - logger.error("[Traceback] \(message(), privacy: .public)") + logger.error("\(message(), privacy: .public)") } ) } diff --git a/Sources/Traceback/Private/TracebackSDKImpl.swift b/Sources/Traceback/Private/TracebackSDKImpl.swift index 5ea6ef0..e72e58a 100644 --- a/Sources/Traceback/Private/TracebackSDKImpl.swift +++ b/Sources/Traceback/Private/TracebackSDKImpl.swift @@ -38,7 +38,7 @@ final class TracebackSDKImpl { } logger.debug("Waiting for universal link") - let linkFromIntent = await linkDetectionActor.waitForValue(timeout: 0.5) + let linkFromIntent = await linkDetectionActor.waitForValue(timeoutSeconds: 0.5) logger.debug("Got universal link: \(linkFromIntent?.absoluteString ?? "none")") logger.info("Checking for post-install link") diff --git a/Sources/Traceback/Private/ValueWaiter.swift b/Sources/Traceback/Private/ValueWaiter.swift index 8bd6a59..ee676d1 100644 --- a/Sources/Traceback/Private/ValueWaiter.swift +++ b/Sources/Traceback/Private/ValueWaiter.swift @@ -5,7 +5,7 @@ actor ValueWaiter { private var continuation: CheckedContinuation? private var alreadyCalled = false - func waitForValue(timeout: TimeInterval) async -> T? { + func waitForValue(timeoutSeconds: TimeInterval) async -> T? { assert(!alreadyCalled) alreadyCalled = true if let pendingValue = self.pendingValue { @@ -15,7 +15,7 @@ actor ValueWaiter { return await withCheckedContinuation { continuation in self.continuation = continuation Task { - try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) self.timeoutExpired() } }