diff --git a/BranchLinkSimulator.xcodeproj/project.pbxproj b/BranchLinkSimulator.xcodeproj/project.pbxproj index 8699936..0811b61 100644 --- a/BranchLinkSimulator.xcodeproj/project.pbxproj +++ b/BranchLinkSimulator.xcodeproj/project.pbxproj @@ -12,6 +12,11 @@ 422D523D2CEFBAD60027F9D7 /* RoundTripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422D523C2CEFBAD60027F9D7 /* RoundTripView.swift */; }; 428539352CF69D8A00084545 /* RoundTrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428539342CF69D8A00084545 /* RoundTrip.swift */; }; 428539372CF69E2300084545 /* RoundTripStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428539362CF69E2300084545 /* RoundTripStore.swift */; }; + B74BD8BC2EC258A800183565 /* ATTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74BD8BA2EC258A800183565 /* ATTManager.swift */; }; + B74BD8BD2EC258A800183565 /* ATTTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74BD8BB2EC258A800183565 /* ATTTestView.swift */; }; + B74BD8C32EC25E9200183565 /* ATTTestViewUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74BD8C12EC25E9200183565 /* ATTTestViewUITests.swift */; }; + B74BD8C42EC25E9200183565 /* ATTManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74BD8BF2EC25E9200183565 /* ATTManagerTests.swift */; }; + B74BD8C52EC25E9200183565 /* ATTBranchIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B74BD8BE2EC25E9200183565 /* ATTBranchIntegrationTests.swift */; }; C10E03962D11EE6C00ECD382 /* BranchSDK in Frameworks */ = {isa = PBXBuildFile; productRef = C10E03952D11EE6C00ECD382 /* BranchSDK */; }; C16B978F2B759F9D00FB0631 /* BranchLinkSimulatorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16B978E2B759F9D00FB0631 /* BranchLinkSimulatorApp.swift */; }; C16B97912B759F9D00FB0631 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16B97902B759F9D00FB0631 /* HomeView.swift */; }; @@ -23,12 +28,36 @@ E7EA36B22DD63A350094FCC9 /* SafariServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E7EA36B12DD63A350094FCC9 /* SafariServices.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + B74BD8CE2EC25F1100183565 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C16B97832B759F9D00FB0631 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C16B978A2B759F9D00FB0631; + remoteInfo = BranchLinkSimulator; + }; + B74BD8DF2EC25FB600183565 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C16B97832B759F9D00FB0631 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C16B978A2B759F9D00FB0631; + remoteInfo = BranchLinkSimulator; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 422D52342CED496A0027F9D7 /* ApiConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiConfiguration.swift; sourceTree = ""; }; 422D52362CED50230027F9D7 /* ApiSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiSettingsView.swift; sourceTree = ""; }; 422D523C2CEFBAD60027F9D7 /* RoundTripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundTripView.swift; sourceTree = ""; }; 428539342CF69D8A00084545 /* RoundTrip.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoundTrip.swift; sourceTree = ""; }; 428539362CF69E2300084545 /* RoundTripStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundTripStore.swift; sourceTree = ""; }; + B74BD8BA2EC258A800183565 /* ATTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ATTManager.swift; sourceTree = ""; }; + B74BD8BB2EC258A800183565 /* ATTTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ATTTestView.swift; sourceTree = ""; }; + B74BD8BE2EC25E9200183565 /* ATTBranchIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ATTBranchIntegrationTests.swift; sourceTree = ""; }; + B74BD8BF2EC25E9200183565 /* ATTManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ATTManagerTests.swift; sourceTree = ""; }; + B74BD8C12EC25E9200183565 /* ATTTestViewUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ATTTestViewUITests.swift; sourceTree = ""; }; + B74BD8CA2EC25F1100183565 /* BranchLinkSimulatorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BranchLinkSimulatorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B74BD8D92EC25FB600183565 /* BranchLinkSimulatorUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BranchLinkSimulatorUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C16B978B2B759F9D00FB0631 /* BranchLinkSimulator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BranchLinkSimulator.app; sourceTree = BUILT_PRODUCTS_DIR; }; C16B978E2B759F9D00FB0631 /* BranchLinkSimulatorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BranchLinkSimulatorApp.swift; sourceTree = ""; }; C16B97902B759F9D00FB0631 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; @@ -43,6 +72,20 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + B74BD8C72EC25F1100183565 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B74BD8D62EC25FB600183565 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C16B97882B759F9D00FB0631 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -56,10 +99,29 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + B74BD8C02EC25E9200183565 /* BranchLinkSimulatorTests */ = { + isa = PBXGroup; + children = ( + B74BD8BE2EC25E9200183565 /* ATTBranchIntegrationTests.swift */, + B74BD8BF2EC25E9200183565 /* ATTManagerTests.swift */, + ); + path = BranchLinkSimulatorTests; + sourceTree = ""; + }; + B74BD8C22EC25E9200183565 /* BranchLinkSimulatorUITests */ = { + isa = PBXGroup; + children = ( + B74BD8C12EC25E9200183565 /* ATTTestViewUITests.swift */, + ); + path = BranchLinkSimulatorUITests; + sourceTree = ""; + }; C16B97822B759F9D00FB0631 = { isa = PBXGroup; children = ( C16B978D2B759F9D00FB0631 /* BranchLinkSimulator */, + B74BD8C02EC25E9200183565 /* BranchLinkSimulatorTests */, + B74BD8C22EC25E9200183565 /* BranchLinkSimulatorUITests */, C16B978C2B759F9D00FB0631 /* Products */, C16B97AF2B87BD8000FB0631 /* Frameworks */, ); @@ -69,6 +131,8 @@ isa = PBXGroup; children = ( C16B978B2B759F9D00FB0631 /* BranchLinkSimulator.app */, + B74BD8CA2EC25F1100183565 /* BranchLinkSimulatorTests.xctest */, + B74BD8D92EC25FB600183565 /* BranchLinkSimulatorUITests.xctest */, ); name = Products; sourceTree = ""; @@ -76,6 +140,8 @@ C16B978D2B759F9D00FB0631 /* BranchLinkSimulator */ = { isa = PBXGroup; children = ( + B74BD8BA2EC258A800183565 /* ATTManager.swift */, + B74BD8BB2EC258A800183565 /* ATTTestView.swift */, C16B97A62B75A5F700FB0631 /* Info.plist */, C16B97A52B75A4F800FB0631 /* BranchLinkSimulator.entitlements */, C16B97A12B75A18A00FB0631 /* AppDelegate.swift */, @@ -113,6 +179,46 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + B74BD8C92EC25F1100183565 /* BranchLinkSimulatorTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B74BD8D02EC25F1100183565 /* Build configuration list for PBXNativeTarget "BranchLinkSimulatorTests" */; + buildPhases = ( + B74BD8C62EC25F1100183565 /* Sources */, + B74BD8C72EC25F1100183565 /* Frameworks */, + B74BD8C82EC25F1100183565 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + B74BD8CF2EC25F1100183565 /* PBXTargetDependency */, + ); + name = BranchLinkSimulatorTests; + packageProductDependencies = ( + ); + productName = BranchLinkSimulatorUITests; + productReference = B74BD8CA2EC25F1100183565 /* BranchLinkSimulatorTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + B74BD8D82EC25FB600183565 /* BranchLinkSimulatorUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B74BD8E12EC25FB600183565 /* Build configuration list for PBXNativeTarget "BranchLinkSimulatorUITests" */; + buildPhases = ( + B74BD8D52EC25FB600183565 /* Sources */, + B74BD8D62EC25FB600183565 /* Frameworks */, + B74BD8D72EC25FB600183565 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + B74BD8E02EC25FB600183565 /* PBXTargetDependency */, + ); + name = BranchLinkSimulatorUITests; + packageProductDependencies = ( + ); + productName = BranchLinkSimulatorUITests; + productReference = B74BD8D92EC25FB600183565 /* BranchLinkSimulatorUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; C16B978A2B759F9D00FB0631 /* BranchLinkSimulator */ = { isa = PBXNativeTarget; buildConfigurationList = C16B97992B759F9E00FB0631 /* Build configuration list for PBXNativeTarget "BranchLinkSimulator" */; @@ -140,9 +246,17 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1500; + LastSwiftUpdateCheck = 1630; LastUpgradeCheck = 1500; TargetAttributes = { + B74BD8C92EC25F1100183565 = { + CreatedOnToolsVersion = 16.3; + TestTargetID = C16B978A2B759F9D00FB0631; + }; + B74BD8D82EC25FB600183565 = { + CreatedOnToolsVersion = 16.3; + TestTargetID = C16B978A2B759F9D00FB0631; + }; C16B978A2B759F9D00FB0631 = { CreatedOnToolsVersion = 15.0; }; @@ -165,11 +279,27 @@ projectRoot = ""; targets = ( C16B978A2B759F9D00FB0631 /* BranchLinkSimulator */, + B74BD8C92EC25F1100183565 /* BranchLinkSimulatorTests */, + B74BD8D82EC25FB600183565 /* BranchLinkSimulatorUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + B74BD8C82EC25F1100183565 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B74BD8D72EC25FB600183565 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C16B97892B759F9D00FB0631 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -182,6 +312,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + B74BD8C62EC25F1100183565 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B74BD8C42EC25E9200183565 /* ATTManagerTests.swift in Sources */, + B74BD8C52EC25E9200183565 /* ATTBranchIntegrationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B74BD8D52EC25FB600183565 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B74BD8C32EC25E9200183565 /* ATTTestViewUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; C16B97872B759F9D00FB0631 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -195,12 +342,101 @@ C16B978F2B759F9D00FB0631 /* BranchLinkSimulatorApp.swift in Sources */, 422D523D2CEFBAD60027F9D7 /* RoundTripView.swift in Sources */, C16B97A02B75A0FA00FB0631 /* DeeplinkDetailView.swift in Sources */, + B74BD8BC2EC258A800183565 /* ATTManager.swift in Sources */, + B74BD8BD2EC258A800183565 /* ATTTestView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + B74BD8CF2EC25F1100183565 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C16B978A2B759F9D00FB0631 /* BranchLinkSimulator */; + targetProxy = B74BD8CE2EC25F1100183565 /* PBXContainerItemProxy */; + }; + B74BD8E02EC25FB600183565 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C16B978A2B759F9D00FB0631 /* BranchLinkSimulator */; + targetProxy = B74BD8DF2EC25FB600183565 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + B74BD8D12EC25F1100183565 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = R63EM248DP; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "io.branch.link-simulator.-BranchLinkSimulatorUITests.BranchLinkSimulatorUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BranchLinkSimulator.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BranchLinkSimulator"; + }; + name = Debug; + }; + B74BD8D22EC25F1100183565 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = R63EM248DP; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "io.branch.link-simulator.-BranchLinkSimulatorUITests.BranchLinkSimulatorUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BranchLinkSimulator.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BranchLinkSimulator"; + }; + name = Release; + }; + B74BD8E22EC25FB600183565 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = R63EM248DP; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "io.branch.link-simulator.-BranchLinkSimulatorUITests.BranchLinkSimulatorUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = BranchLinkSimulator; + }; + name = Debug; + }; + B74BD8E32EC25FB600183565 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = R63EM248DP; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "io.branch.link-simulator.-BranchLinkSimulatorUITests.BranchLinkSimulatorUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = BranchLinkSimulator; + }; + name = Release; + }; C16B97972B759F9E00FB0631 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -345,7 +581,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5; + MARKETING_VERSION = 2.7; PRODUCT_BUNDLE_IDENTIFIER = "io.branch.link-simulator"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -382,7 +618,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5; + MARKETING_VERSION = 2.7; PRODUCT_BUNDLE_IDENTIFIER = "io.branch.link-simulator"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -397,6 +633,24 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + B74BD8D02EC25F1100183565 /* Build configuration list for PBXNativeTarget "BranchLinkSimulatorTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B74BD8D12EC25F1100183565 /* Debug */, + B74BD8D22EC25F1100183565 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B74BD8E12EC25FB600183565 /* Build configuration list for PBXNativeTarget "BranchLinkSimulatorUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B74BD8E22EC25FB600183565 /* Debug */, + B74BD8E32EC25FB600183565 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; C16B97862B759F9D00FB0631 /* Build configuration list for PBXProject "BranchLinkSimulator" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/BranchLinkSimulator/ATTManager.swift b/BranchLinkSimulator/ATTManager.swift new file mode 100644 index 0000000..c63bfca --- /dev/null +++ b/BranchLinkSimulator/ATTManager.swift @@ -0,0 +1,189 @@ +// +// ATTManager.swift +// BranchLinkSimulator +// +// Created for comprehensive ATT testing +// + +import Foundation +import AppTrackingTransparency +import AdSupport +import SwiftUI + +/// Comprehensive ATT (App Tracking Transparency) Manager for testing all scenarios +class ATTManager: ObservableObject { + + // MARK: - Published Properties + @Published var authorizationStatus: ATTrackingManager.AuthorizationStatus = .notDetermined + @Published var idfa: String = "00000000-0000-0000-0000-000000000000" + @Published var lastRequestDate: Date? + @Published var statusHistory: [ATTStatusEntry] = [] + + // MARK: - Initialization + init() { + // Initialize synchronously to avoid race conditions with SwiftUI view rendering + // This ensures the view has valid data immediately upon first render + if #available(iOS 14, *) { + authorizationStatus = ATTrackingManager.trackingAuthorizationStatus + } else { + authorizationStatus = .authorized + } + updateIDFA() + + // Add initial history entry + addStatusEntry(status: authorizationStatus, event: "App Initialized") + } + + // MARK: - Public Methods + + /// Request IDFA permission from the user + /// - Parameter completion: Callback with the authorization status + func requestIDFAPermission(completion: ((ATTrackingManager.AuthorizationStatus) -> Void)? = nil) { + if #available(iOS 14, *) { + ATTrackingManager.requestTrackingAuthorization { [weak self] status in + guard let self = self else { return } + + // Ensure all UI updates happen on main thread + DispatchQueue.main.async { + self.authorizationStatus = status + self.lastRequestDate = Date() + self.updateIDFA() + self.addStatusEntry(status: status, event: "Permission Requested") + + self.logStatusChange(status: status) + completion?(status) + } + } + } else { + // For iOS versions < 14, tracking is allowed by default + DispatchQueue.main.async { + self.authorizationStatus = .authorized + self.updateIDFA() + completion?(.authorized) + } + } + } + + /// Update the current authorization status + func updateCurrentStatus() { + DispatchQueue.main.async { + if #available(iOS 14, *) { + self.authorizationStatus = ATTrackingManager.trackingAuthorizationStatus + } else { + self.authorizationStatus = .authorized + } + self.updateIDFA() + } + } + + /// Get detailed status information + func getStatusInfo() -> ATTStatusInfo { + return ATTStatusInfo( + status: authorizationStatus, + idfa: idfa, + lastRequestDate: lastRequestDate, + isTrackingEnabled: authorizationStatus == .authorized, + canRequestPermission: authorizationStatus == .notDetermined + ) + } + + /// Check if we can show the ATT prompt + func canShowATTPrompt() -> Bool { + if #available(iOS 14, *) { + return ATTrackingManager.trackingAuthorizationStatus == .notDetermined + } + return false + } + + /// Reset tracking status (for testing - requires app reinstall in production) + func resetForTesting() { + DispatchQueue.main.async { + self.statusHistory.removeAll() + self.updateCurrentStatus() + // Add status entry after updateCurrentStatus completes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.addStatusEntry(status: self.authorizationStatus, event: "Status Reset for Testing") + } + } + } + + // MARK: - Private Methods + + private func updateIDFA() { + if authorizationStatus == .authorized { + let idfaUUID = ASIdentifierManager.shared().advertisingIdentifier + self.idfa = idfaUUID.uuidString + } else { + self.idfa = "00000000-0000-0000-0000-000000000000" + } + } + + private func logStatusChange(status: ATTrackingManager.AuthorizationStatus) { + let statusString = statusToString(status) + print("πŸ“Š ATT Status Changed: \(statusString)") + print("πŸ“± IDFA: \(idfa)") + } + + private func addStatusEntry(status: ATTrackingManager.AuthorizationStatus, event: String) { + let entry = ATTStatusEntry( + date: Date(), + status: status, + idfa: idfa, + event: event + ) + statusHistory.insert(entry, at: 0) + + // Keep only last 50 entries + if statusHistory.count > 50 { + statusHistory = Array(statusHistory.prefix(50)) + } + } + + func statusToString(_ status: ATTrackingManager.AuthorizationStatus) -> String { + switch status { + case .notDetermined: + return "Not Determined" + case .restricted: + return "Restricted" + case .denied: + return "Denied" + case .authorized: + return "Authorized" + @unknown default: + return "Unknown" + } + } + + func statusToEmoji(_ status: ATTrackingManager.AuthorizationStatus) -> String { + switch status { + case .notDetermined: + return "❓" + case .restricted: + return "πŸ”’" + case .denied: + return "❌" + case .authorized: + return "βœ…" + @unknown default: + return "⚠️" + } + } +} + +// MARK: - Supporting Types + +struct ATTStatusInfo { + let status: ATTrackingManager.AuthorizationStatus + let idfa: String + let lastRequestDate: Date? + let isTrackingEnabled: Bool + let canRequestPermission: Bool +} + +struct ATTStatusEntry: Identifiable { + let id = UUID() + let date: Date + let status: ATTrackingManager.AuthorizationStatus + let idfa: String + let event: String +} diff --git a/BranchLinkSimulator/ATTTestView.swift b/BranchLinkSimulator/ATTTestView.swift new file mode 100644 index 0000000..14e79f6 --- /dev/null +++ b/BranchLinkSimulator/ATTTestView.swift @@ -0,0 +1,391 @@ +// +// ATTTestView.swift +// BranchLinkSimulator +// +// Created for comprehensive ATT testing +// + +import SwiftUI +import AppTrackingTransparency + +struct ATTTestView: View { + @StateObject private var attManager = ATTManager() + @State private var showingRequestAlert = false + @State private var showingStatusHistory = false + @State private var showingCopyConfirmation = false + + var body: some View { + List { + // Current Status Section + Section(header: Text("Current ATT Status") + .accessibilityIdentifier("Current ATT Status")) { + HStack { + Text("Status") + .font(.headline) + .accessibilityIdentifier("Status") + Spacer() + HStack(spacing: 4) { + Text(attManager.statusToEmoji(attManager.authorizationStatus)) + .accessibilityIdentifier("statusEmoji") + Text(attManager.statusToString(attManager.authorizationStatus)) + .foregroundColor(statusColor) + .accessibilityIdentifier("statusValue") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("statusDisplay") + } + .padding(.vertical, 4) + + HStack { + Text("IDFA") + .font(.headline) + .accessibilityIdentifier("IDFA") + Spacer() + Text(attManager.idfa) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + .accessibilityIdentifier("idfaValue") + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + UIPasteboard.general.string = attManager.idfa + showingCopyConfirmation = true + } + .contextMenu { + Button(action: { + UIPasteboard.general.string = attManager.idfa + showingCopyConfirmation = true + }) { + Label("Copy IDFA", systemImage: "doc.on.doc") + } + } + + if let lastRequest = attManager.lastRequestDate { + HStack { + Text("Last Request") + .font(.headline) + .accessibilityIdentifier("lastRequestLabel") + Spacer() + Text(lastRequest, style: .relative) + .foregroundColor(.secondary) + .accessibilityIdentifier("lastRequestValue") + } + .padding(.vertical, 4) + .accessibilityIdentifier("lastRequestRow") + } + + HStack { + Text("Tracking Enabled") + .font(.headline) + .accessibilityIdentifier("Tracking Enabled") + Spacer() + Image(systemName: attManager.authorizationStatus == .authorized ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(attManager.authorizationStatus == .authorized ? .green : .red) + .accessibilityIdentifier(attManager.authorizationStatus == .authorized ? "checkmark.circle.fill" : "xmark.circle.fill") + } + .padding(.vertical, 4) + } + + // Actions Section + Section(header: Text("Actions") + .accessibilityIdentifier("Actions")) { + Button(action: { + requestPermission() + }) { + Label("Request ATT Permission", systemImage: "hand.raised.fill") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .padding(.horizontal) + .background(canRequestPermission ? Color.blue : Color.gray) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(!canRequestPermission) + .accessibilityIdentifier("Request ATT Permission") + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + + Button(action: { + attManager.updateCurrentStatus() + }) { + Label("Refresh Status", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .padding(.horizontal) + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(10) + } + .accessibilityIdentifier("Refresh Status") + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + + Button(action: { + openSettings() + }) { + Label("Open App Settings", systemImage: "gear") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .padding(.horizontal) + .background(Color.orange) + .foregroundColor(.white) + .cornerRadius(10) + } + .accessibilityIdentifier("Open App Settings") + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + } + + // Status History Section + Section(header: HStack { + Text("Status History") + .accessibilityIdentifier("Status History") + Spacer() + Button(action: { + attManager.resetForTesting() + }) { + Text("Clear") + .font(.caption) + .foregroundColor(.red) + } + .accessibilityIdentifier("Clear") + }) { + if attManager.statusHistory.isEmpty { + Text("No history yet") + .foregroundColor(.secondary) + .italic() + .padding(.vertical, 8) + .accessibilityIdentifier("No history yet") + } else { + ForEach(attManager.statusHistory.prefix(10)) { entry in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(attManager.statusToEmoji(entry.status)) + .font(.title3) + Text(entry.event) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + Text(entry.date, style: .time) + .font(.caption) + .foregroundColor(.secondary) + } + Text(attManager.statusToString(entry.status)) + .font(.caption) + .foregroundColor(.secondary) + Text("IDFA: \(entry.idfa)") + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + } + .padding(.vertical, 6) + } + } + } + + // Test Scenarios Section + Section(header: Text("Test Scenarios & Expected Behavior") + .accessibilityIdentifier("Test Scenarios & Expected Behavior")) { + TestScenarioRow( + emoji: "❓", + title: "Not Determined", + description: "Initial state. ATT prompt can be shown.", + isCurrentState: attManager.authorizationStatus == .notDetermined + ) + + TestScenarioRow( + emoji: "βœ…", + title: "Authorized", + description: "User granted permission. IDFA is available.", + isCurrentState: attManager.authorizationStatus == .authorized + ) + + TestScenarioRow( + emoji: "❌", + title: "Denied", + description: "User denied permission. IDFA returns zeros.", + isCurrentState: attManager.authorizationStatus == .denied + ) + + TestScenarioRow( + emoji: "πŸ”’", + title: "Restricted", + description: "Tracking restricted by parental controls or MDM.", + isCurrentState: attManager.authorizationStatus == .restricted + ) + } + + // Integration Info Section + Section(header: Text("Branch SDK Integration") + .accessibilityIdentifier("Branch SDK Integration")) { + VStack(alignment: .leading, spacing: 10) { + Text("How Branch Uses ATT") + .font(.headline) + .padding(.bottom, 4) + .accessibilityIdentifier("How Branch Uses ATT") + + Text("β€’ The Branch SDK automatically checks ATT status") + .font(.caption) + + Text("β€’ If authorized, IDFA is included in attribution") + .font(.caption) + + Text("β€’ If denied/restricted, Branch uses alternative identifiers") + .font(.caption) + + Text("β€’ Branch respects user privacy choices") + .font(.caption) + } + .padding(.vertical, 8) + } + + // Testing Notes Section + Section(header: Text("Testing Notes") + .accessibilityIdentifier("Testing Notes")) { + VStack(alignment: .leading, spacing: 10) { + Text("⚠️ Important") + .font(.headline) + .foregroundColor(.orange) + .padding(.bottom, 4) + .accessibilityIdentifier("⚠️ Important") + + Text("β€’ ATT prompt can only be shown once per app install") + .font(.caption) + + Text("β€’ To test again, delete and reinstall the app") + .font(.caption) + + Text("β€’ Or reset 'Advertising Identifier' in Settings > Privacy") + .font(.caption) + + Text("β€’ Status changes take effect immediately") + .font(.caption) + } + .padding(.vertical, 8) + } + } + .navigationTitle("ATT Testing") + .navigationBarTitleDisplayMode(.large) + .onAppear { + attManager.updateCurrentStatus() + } + .overlay( + Group { + if showingCopyConfirmation { + VStack { + Spacer() + Text("IDFA Copied!") + .padding() + .background(Color.black.opacity(0.8)) + .foregroundColor(.white) + .cornerRadius(8) + .accessibilityIdentifier("copyConfirmation") + Spacer() + } + .transition(.opacity) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + showingCopyConfirmation = false + } + } + } + } + } + ) + .alert("Request ATT Permission?", isPresented: $showingRequestAlert) { + Button("Cancel", role: .cancel) { } + Button("Request") { + requestPermission() + } + } message: { + Text("This will show the system ATT permission dialog. You can only request this once per app install.") + } + } + + // MARK: - Computed Properties + + private var statusColor: Color { + switch attManager.authorizationStatus { + case .authorized: + return .green + case .denied: + return .red + case .restricted: + return .orange + case .notDetermined: + return .blue + @unknown default: + return .gray + } + } + + private var canRequestPermission: Bool { + attManager.canShowATTPrompt() + } + + // MARK: - Methods + + private func requestPermission() { + attManager.requestIDFAPermission { status in + print("ATT Permission Result: \(attManager.statusToString(status))") + } + } + + private func openSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } +} + +// MARK: - Supporting Views + +struct TestScenarioRow: View { + let emoji: String + let title: String + let description: String + let isCurrentState: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(emoji) + .font(.title2) + .accessibilityIdentifier(emoji) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(title) + .font(.subheadline) + .fontWeight(.semibold) + .accessibilityIdentifier(title) + + if isCurrentState { + Text("CURRENT") + .font(.caption2) + .fontWeight(.bold) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(4) + .accessibilityIdentifier("CURRENT") + } + } + + Text(description) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Preview + +#Preview { + NavigationView { + ATTTestView() + } +} diff --git a/BranchLinkSimulator/HomeView.swift b/BranchLinkSimulator/HomeView.swift index 9332602..1b0947a 100644 --- a/BranchLinkSimulator/HomeView.swift +++ b/BranchLinkSimulator/HomeView.swift @@ -28,13 +28,31 @@ struct HomeView: View { var body: some View { NavigationView { - VStack { + ZStack { if deepLinkViewModel.deepLinkHandled, let deepLinkData = deepLinkViewModel.deepLinkData { - NavigationLink(destination: DeeplinkDetailView(pageTitle: deepLinkData["page"] as? String ?? "Data", deepLinkParameters: deepLinkData), isActive: $deepLinkViewModel.deepLinkHandled) { + NavigationLink( + destination: DeeplinkDetailView( + pageTitle: deepLinkData["page"] as? String ?? "Data", + deepLinkParameters: deepLinkData + ) + ) { EmptyView() } - } else { + .opacity(0) + .simultaneousGesture(TapGesture().onEnded { + // Trigger navigation + }) + } + + if !deepLinkViewModel.deepLinkHandled { List { + Section(header: Text("Testing & Diagnostics")) { + NavigationLink(destination: ATTTestView()) { + Label("ATT Testing", systemImage: "shield.checkered") + } + } + .headerProminence(.standard) + Section(header: Text("Deep Link Pages")) { NavigationLink(destination: DeeplinkDetailView(pageTitle: "Tree", deepLinkParameters: [:])) { Label("Go to Tree", systemImage: "tree.fill") diff --git a/BranchLinkSimulator/Info.plist b/BranchLinkSimulator/Info.plist index e59842c..633ae57 100644 --- a/BranchLinkSimulator/Info.plist +++ b/BranchLinkSimulator/Info.plist @@ -35,5 +35,7 @@ hoplink.branchcustom.xyz hop.link + NSUserTrackingUsageDescription + This app uses tracking to provide better attribution and personalized experiences. Your data helps us improve our services. diff --git a/BranchLinkSimulatorTests/ATTBranchIntegrationTests.swift b/BranchLinkSimulatorTests/ATTBranchIntegrationTests.swift new file mode 100644 index 0000000..9f166a3 --- /dev/null +++ b/BranchLinkSimulatorTests/ATTBranchIntegrationTests.swift @@ -0,0 +1,419 @@ +// +// ATTBranchIntegrationTests.swift +// BranchLinkSimulatorTests +// +// Integration tests for ATT with Branch SDK +// + +import Testing +import AppTrackingTransparency +import AdSupport +import BranchSDK +@testable import BranchLinkSimulator + +@MainActor +@Suite("ATT Branch Integration Tests") +struct ATTBranchIntegrationTests { + + var attManager: ATTManager + + init() { + self.attManager = ATTManager() + } + + // MARK: - Helper Methods + + /// Check if running on simulator + private var isSimulator: Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } + + /// Check if IDFA is valid (either real UUID or zeros on simulator when authorized) + private func isValidIDFAForAuthorized(_ idfa: String) -> Bool { + // Valid UUID format check + guard UUID(uuidString: idfa) != nil else { + return false + } + + // On simulator, authorized can still give zeros - this is correct behavior + // On device, authorized should give real IDFA (non-zeros) + if isSimulator { + // Simulator: accept any valid UUID (zeros or real) + return true + } else { + // Real device: expect non-zero IDFA when authorized + return idfa != "00000000-0000-0000-0000-000000000000" + } + } + + // MARK: - IDFA Integration Tests + + @Test("Authorized status provides IDFA to Branch", + .enabled(if: ATTrackingManager.trackingAuthorizationStatus == .authorized)) + func authorizedStatusProvidesIDFAToBranch() { + // When: Getting IDFA + let idfa = attManager.idfa + + // Then: IDFA should be valid UUID + // On simulator: can be zeros (documented iOS behavior) + // On device: should be real UUID + #expect(UUID(uuidString: idfa) != nil, "IDFA should be valid UUID when authorized") + #expect(isValidIDFAForAuthorized(idfa), "IDFA should be valid for authorized state (zeros on simulator, real UUID on device)") + } + + @Test("Denied status returns zero IDFA", + .enabled(if: ATTrackingManager.trackingAuthorizationStatus != .authorized)) + func deniedStatusReturnsZeroIDFA() { + // When: Getting IDFA + let idfa = attManager.idfa + + // Then: IDFA should be zeros + #expect(idfa == "00000000-0000-0000-0000-000000000000", "IDFA should be zeros when not authorized") + } + + @Test("Branch SDK can access IDFA") + func branchSDKCanAccessIDFA() { + // Given: ATT Manager with current status + let statusInfo = attManager.getStatusInfo() + + // When: Checking if IDFA is available + let idfaAvailable = statusInfo.isTrackingEnabled + + // Then: IDFA availability should match tracking status + if statusInfo.status == .authorized { + #expect(idfaAvailable, "IDFA should be available when authorized") + #expect(UUID(uuidString: statusInfo.idfa) != nil, "IDFA should be valid UUID when authorized") + // Note: On simulator, IDFA can be zeros even when authorized + } else { + #expect(!idfaAvailable, "IDFA should not be available when not authorized") + #expect(statusInfo.idfa == "00000000-0000-0000-0000-000000000000", "IDFA should be zeros when not authorized") + } + } + + // MARK: - Branch SDK Attribution Tests + + @Test("Branch receives correct IDFA based on ATT status") + func branchReceivesCorrectIDFABasedOnATTStatus() { + // Given: Current ATT status + let statusInfo = attManager.getStatusInfo() + + // When: Branch SDK makes attribution request + // Then: IDFA inclusion should match authorization status + if statusInfo.isTrackingEnabled { + // Authorized: IDFA should be valid UUID (can be zeros on simulator) + #expect(UUID(uuidString: statusInfo.idfa) != nil, + "When tracking is enabled, IDFA should be valid UUID") + } else { + #expect(statusInfo.idfa == "00000000-0000-0000-0000-000000000000", + "When tracking is disabled, IDFA should be zeros") + } + } + + @Test("Branch SDK handles all ATT states") + func branchSDKHandlesAllATTStates() { + // Given: All possible ATT states + let allStates: [ATTrackingManager.AuthorizationStatus] = [ + .notDetermined, + .restricted, + .denied, + .authorized + ] + + // When: Checking each state + for state in allStates { + // Then: Should have appropriate behavior + let expectedZeroIDFA = state != .authorized + + if expectedZeroIDFA { + // Not authorized states should use zeros + #expect(true, "State \(attManager.statusToString(state)) should use zero IDFA") + } else { + // Authorized state should use real IDFA (or zeros on simulator) + #expect(true, "State \(attManager.statusToString(state)) should use real IDFA") + } + } + } + + // MARK: - Branch Event Logging Tests + + @Test("Branch event includes ATT status") + func branchEventIncludesATTStatus() { + // Given: Current ATT status + let currentStatus = attManager.authorizationStatus + + // When: Sending a Branch event + // (Simulating event creation) + let event = BranchEvent.standardEvent(.purchase) + + // Then: Event should be created successfully + #expect(event != nil, "Branch event should be created regardless of ATT status") + + // Note: Branch SDK internally handles IDFA based on ATT status + print("Current ATT Status: \(attManager.statusToString(currentStatus))") + print("IDFA: \(attManager.idfa)") + } + + @Test("Branch event with authorized ATT", + .enabled(if: ATTrackingManager.trackingAuthorizationStatus == .authorized)) + func branchEventWithAuthorizedATT() async { + // When: Creating and logging a Branch event + let event = BranchEvent.standardEvent(.purchase) + event.alias = "test_att_integration" + event.customData["att_status"] = attManager.statusToString(attManager.authorizationStatus) + event.customData["idfa"] = attManager.idfa + + // Then: Event should contain IDFA + #expect(event.customData["idfa"] != nil, "Event should contain IDFA") + + // On simulator, IDFA can be zeros even when authorized + if let idfaValue = event.customData["idfa"] as? String { + #expect(UUID(uuidString: idfaValue) != nil, "Event IDFA should be valid UUID when authorized") + } + } + + @Test("Branch event with denied ATT", + .enabled(if: ATTrackingManager.trackingAuthorizationStatus != .authorized)) + func branchEventWithDeniedATT() async { + // When: Creating and logging a Branch event + let event = BranchEvent.standardEvent(.purchase) + event.alias = "test_att_integration" + event.customData["att_status"] = attManager.statusToString(attManager.authorizationStatus) + event.customData["idfa"] = attManager.idfa + + // Then: Event should contain zero IDFA + #expect(event.customData["idfa"] != nil, "Event should contain IDFA") + #expect(event.customData["idfa"] as? String == "00000000-0000-0000-0000-000000000000", + "Event IDFA should be zeros when not authorized") + } + + // MARK: - Branch Deep Link Attribution Tests + + @Test("Branch deep link attribution with ATT") + func branchDeepLinkAttributionWithATT() { + // Given: ATT Manager with status + let statusInfo = attManager.getStatusInfo() + + // When: Branch handles deep link + // (Simulating deep link handling) + let branchInstance = Branch.getInstance() + #expect(branchInstance != nil, "Branch instance should exist") + + // Then: Attribution should work regardless of ATT status + // Branch uses alternative identifiers when IDFA is not available + print("Deep link attribution available with status: \(attManager.statusToString(statusInfo.status))") + print("Tracking enabled: \(statusInfo.isTrackingEnabled)") + } + + // MARK: - Privacy Compliance Tests + + @Test("Branch respects user privacy choices") + func branchRespectsUserPrivacyChoices() { + // Given: User's ATT choice + let userChoice = attManager.authorizationStatus + + // When: Checking IDFA availability + let statusInfo = attManager.getStatusInfo() + + // Then: Branch should respect user's choice + switch userChoice { + case .authorized: + // User allowed tracking - IDFA should be available + #expect(statusInfo.isTrackingEnabled, "Tracking should be enabled when authorized") + #expect(UUID(uuidString: statusInfo.idfa) != nil, "IDFA should be valid UUID when authorized") + + case .denied, .restricted: + // User denied tracking - IDFA should be zeros + #expect(!statusInfo.isTrackingEnabled, "Tracking should be disabled when denied/restricted") + #expect(statusInfo.idfa == "00000000-0000-0000-0000-000000000000", + "IDFA should be zeros when user denied tracking") + + case .notDetermined: + // User hasn't decided - IDFA should be zeros until permission granted + #expect(!statusInfo.isTrackingEnabled, "Tracking should be disabled when not determined") + #expect(statusInfo.idfa == "00000000-0000-0000-0000-000000000000", + "IDFA should be zeros when permission not yet granted") + + @unknown default: + Issue.record("Unknown ATT status") + } + } + + @Test("Branch uses alternative identifiers when IDFA unavailable", + .enabled(if: ATTrackingManager.trackingAuthorizationStatus != .authorized)) + func branchUsesAlternativeIdentifiersWhenIDFAUnavailable() { + // When: Branch SDK operates without IDFA + let branchInstance = Branch.getInstance() + #expect(branchInstance != nil, "Branch should work without IDFA") + + // Then: Branch should use alternative identifiers + // (Branch internally uses IDFV and other identifiers) + print("Branch operates with alternative identifiers when IDFA unavailable") + #expect(true, "Branch should use alternative identifiers") + } + + // MARK: - Session Management Tests + + @Test("Branch session with different ATT states") + func branchSessionWithDifferentATTStates() { + // Given: Current ATT status + let initialStatus = attManager.authorizationStatus + + // When: Branch session is active + let branchInstance = Branch.getInstance() + #expect(branchInstance != nil, "Branch instance should exist") + + // Then: Session should work with any ATT status + print("Branch session working with ATT status: \(attManager.statusToString(initialStatus))") + #expect(true, "Branch session should work regardless of ATT status") + } + + // MARK: - Attribution Accuracy Tests + + @Test("Attribution accuracy with authorized ATT", + .enabled(if: ATTrackingManager.trackingAuthorizationStatus == .authorized)) + func attributionAccuracyWithAuthorizedATT() { + // When: Branch performs attribution + let statusInfo = attManager.getStatusInfo() + + // Then: Attribution should be most accurate with IDFA + #expect(statusInfo.isTrackingEnabled, "Tracking should be enabled") + #expect(UUID(uuidString: statusInfo.idfa) != nil, + "IDFA should be valid UUID for attribution (can be zeros on simulator)") + } + + @Test("Attribution without IDFA", + .enabled(if: ATTrackingManager.trackingAuthorizationStatus != .authorized)) + func attributionWithoutIDFA() { + // When: Branch performs attribution without IDFA + let statusInfo = attManager.getStatusInfo() + + // Then: Attribution should still work with alternative methods + #expect(!statusInfo.isTrackingEnabled, "Tracking should be disabled") + #expect(statusInfo.idfa == "00000000-0000-0000-0000-000000000000", + "IDFA should be zeros") + + // Branch uses IDFV and other identifiers for attribution + print("Branch attribution works without IDFA using alternative identifiers") + } + + // MARK: - Metadata Tests + + @Test("Branch metadata includes ATT status") + func branchMetadataIncludesATTStatus() { + // Given: ATT Manager with status + let statusInfo = attManager.getStatusInfo() + + // When: Preparing Branch metadata + let metadata: [String: Any] = [ + "att_status": attManager.statusToString(statusInfo.status), + "tracking_enabled": statusInfo.isTrackingEnabled, + "idfa_available": statusInfo.idfa != "00000000-0000-0000-0000-000000000000" + ] + + // Then: Metadata should contain ATT information + #expect(metadata["att_status"] != nil, "Metadata should include ATT status") + #expect(metadata["tracking_enabled"] != nil, "Metadata should include tracking enabled flag") + #expect(metadata["idfa_available"] != nil, "Metadata should include IDFA availability") + } + + // MARK: - Error Handling Tests + + @Test("Branch handles ATT status changes") + func branchHandlesATTStatusChanges() async { + // Given: Initial ATT status + let initialStatus = attManager.authorizationStatus + + // When: Status potentially changes (simulated) + attManager.updateCurrentStatus() + + // Wait for update + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then: Branch should adapt to new status + let newStatus = attManager.authorizationStatus + #expect(newStatus != nil, "New status should be available") + + print("Branch adapts from \(attManager.statusToString(initialStatus)) to \(attManager.statusToString(newStatus))") + } + + // MARK: - Performance Tests + + @Test("Branch performance with IDFA", + .timeLimit(.minutes(1)), + .enabled(if: ATTrackingManager.trackingAuthorizationStatus == .authorized)) + func branchPerformanceWithIDFA() { + // When: Measuring performance with IDFA + for _ in 0..<10 { + let event = BranchEvent.standardEvent(.purchase) + event.customData["idfa"] = attManager.idfa + // Event created with IDFA + } + + // Then: Should be fast + } + + @Test("Branch performance without IDFA", + .timeLimit(.minutes(1)), + .enabled(if: ATTrackingManager.trackingAuthorizationStatus != .authorized)) + func branchPerformanceWithoutIDFA() { + // When: Measuring performance without IDFA + for _ in 0..<10 { + let event = BranchEvent.standardEvent(.purchase) + event.customData["idfa"] = attManager.idfa + // Event created without real IDFA + } + + // Then: Should be fast (no performance penalty) + } + + // MARK: - Real-World Scenario Tests + + @Test("Complete user journey with ATT") + func completeUserJourneyWithATT() async { + // Given: User starts app + let initialStatus = attManager.authorizationStatus + print("1. App launched with ATT status: \(attManager.statusToString(initialStatus))") + + // When: User interacts with app + // Scenario: Send event + let event = BranchEvent.standardEvent(.addToCart) + event.customData["att_status"] = attManager.statusToString(initialStatus) + event.customData["idfa"] = attManager.idfa + + // Then: Event should be created with appropriate data + #expect(event != nil, "Event should be created") + print("2. Event created with IDFA: \(attManager.idfa)") + + // Scenario: Refresh status + attManager.updateCurrentStatus() + try? await Task.sleep(nanoseconds: 100_000_000) + + let updatedStatus = attManager.authorizationStatus + print("3. Status refreshed: \(attManager.statusToString(updatedStatus))") + + // Then: App should continue working smoothly + #expect(true, "Complete user journey completed successfully") + } + + // MARK: - Documentation Tests + + @Test("Branch ATT integration is documented") + func branchATTIntegrationIsDocumented() { + // This test verifies that the integration points are clear + // In a real app, you would check that documentation exists + + // Given: ATT Manager + let manager = ATTManager() + + // Then: All integration points should be clear + #expect(manager.getStatusInfo() != nil, "Status info available for integration") + #expect(manager.authorizationStatus != nil, "Authorization status available") + #expect(manager.idfa != nil, "IDFA available for integration") + + print("βœ… Branch ATT integration points are well-defined") + } +} diff --git a/BranchLinkSimulatorTests/ATTManagerTests.swift b/BranchLinkSimulatorTests/ATTManagerTests.swift new file mode 100644 index 0000000..54ebdba --- /dev/null +++ b/BranchLinkSimulatorTests/ATTManagerTests.swift @@ -0,0 +1,438 @@ +// +// ATTManagerTests.swift +// BranchLinkSimulatorTests +// +// Created for comprehensive ATT testing +// + +import Testing +import AppTrackingTransparency +import AdSupport +@testable import BranchLinkSimulator + +@MainActor +@Suite("ATTManager Tests") +struct ATTManagerTests { + + var sut: ATTManager + + init() { + self.sut = ATTManager() + } + + // MARK: - Initialization Tests + + @Test("Initialization sets properties correctly") + func initialization() { + // Given: Fresh ATTManager instance + // When: Manager is initialized + // Then: Properties should be set + #expect(sut.authorizationStatus != nil, "Authorization status should be set") + #expect(sut.idfa != nil, "IDFA should be set") + #expect(sut.statusHistory.count == 0, "History should be empty initially") + } + + @Test("Initial status matches system status") + func initialStatusIsSet() { + // Given: Fresh ATTManager instance + // When: Manager is initialized + // Then: Status should match system status + if #available(iOS 14, *) { + let systemStatus = ATTrackingManager.trackingAuthorizationStatus + #expect(sut.authorizationStatus == systemStatus, "Initial status should match system") + } + } + + // MARK: - Status Conversion Tests + + @Test("Status to string converts Not Determined") + func statusToStringConvertsNotDetermined() { + // Given: Not determined status + let status: ATTrackingManager.AuthorizationStatus = .notDetermined + + // When: Converting to string + let result = sut.statusToString(status) + + // Then: Should return correct string + #expect(result == "Not Determined") + } + + @Test("Status to string converts Authorized") + func statusToStringConvertsAuthorized() { + // Given: Authorized status + let status: ATTrackingManager.AuthorizationStatus = .authorized + + // When: Converting to string + let result = sut.statusToString(status) + + // Then: Should return correct string + #expect(result == "Authorized") + } + + @Test("Status to string converts Denied") + func statusToStringConvertsDenied() { + // Given: Denied status + let status: ATTrackingManager.AuthorizationStatus = .denied + + // When: Converting to string + let result = sut.statusToString(status) + + // Then: Should return correct string + #expect(result == "Denied") + } + + @Test("Status to string converts Restricted") + func statusToStringConvertsRestricted() { + // Given: Restricted status + let status: ATTrackingManager.AuthorizationStatus = .restricted + + // When: Converting to string + let result = sut.statusToString(status) + + // Then: Should return correct string + #expect(result == "Restricted") + } + + @Test("Status to string converts all cases") + func statusToStringConvertsAllCases() { + // Given: All possible ATT authorization statuses + let statuses: [ATTrackingManager.AuthorizationStatus] = [ + .notDetermined, + .restricted, + .denied, + .authorized + ] + + // When: Converting each status to string + // Then: Should return appropriate string for each + for status in statuses { + let stringValue = sut.statusToString(status) + #expect(!stringValue.isEmpty, "Status string should not be empty for \(status)") + #expect(stringValue != "Unknown", "Status \(status) should have known string representation") + } + } + + // MARK: - Emoji Conversion Tests + + @Test("Status to emoji returns valid emoji") + func statusToEmojiReturnsValidEmoji() { + // Given: All possible ATT authorization statuses + let statuses: [ATTrackingManager.AuthorizationStatus] = [ + .notDetermined, + .restricted, + .denied, + .authorized + ] + + // When: Converting each status to emoji + // Then: Should return appropriate emoji for each + for status in statuses { + let emoji = sut.statusToEmoji(status) + #expect(!emoji.isEmpty, "Emoji should not be empty for \(status)") + #expect(emoji.count >= 1, "Emoji should be at least one character") + } + } + + @Test("Status to emoji returns correct emojis") + func statusToEmojiReturnsCorrectEmojis() { + // Given: Each status + // Then: Should return correct emoji + #expect(sut.statusToEmoji(.notDetermined) == "❓") + #expect(sut.statusToEmoji(.authorized) == "βœ…") + #expect(sut.statusToEmoji(.denied) == "❌") + #expect(sut.statusToEmoji(.restricted) == "πŸ”’") + } + + // MARK: - IDFA Tests + + @Test("IDFA format is valid UUID") + func idfaFormatIsValidUUID() { + // Given: Current IDFA + let idfa = sut.idfa + + // When: Checking format + // Then: Should be valid UUID format + let uuid = UUID(uuidString: idfa) + #expect(uuid != nil, "IDFA should be valid UUID format") + } + + @Test("IDFA is zeros when not authorized") + func idfaIsZerosWhenNotAuthorized() { + // Given: ATT status is not authorized + // When: Status is denied or restricted or not determined + // Then: IDFA should be all zeros + let zeroIDFA = "00000000-0000-0000-0000-000000000000" + + if sut.authorizationStatus != .authorized { + #expect(sut.idfa == zeroIDFA, "IDFA should be zeros when not authorized") + } + } + + @Test("IDFA is valid when authorized") + func idfaIsValidWhenAuthorized() { + // Given: ATT status is authorized + // When: User has granted permission + // Then: IDFA should be valid (not zeros or may be zeros depending on device) + if sut.authorizationStatus == .authorized { + let idfa = sut.idfa + #expect(UUID(uuidString: idfa) != nil, "IDFA should be valid UUID when authorized") + } + } + + // MARK: - Status Info Tests + + @Test("Get status info returns complete info") + func getStatusInfoReturnsCompleteInfo() { + // Given: ATTManager with current status + // When: Requesting status info + let statusInfo = sut.getStatusInfo() + + // Then: Should return complete status information + #expect(statusInfo.status != nil) + #expect(!statusInfo.idfa.isEmpty) + #expect(statusInfo.isTrackingEnabled == (statusInfo.status == .authorized)) + #expect(statusInfo.canRequestPermission == (statusInfo.status == .notDetermined)) + } + + @Test("Get status info tracking enabled matches status") + func getStatusInfoTrackingEnabledMatchesStatus() { + // Given: Current status + let statusInfo = sut.getStatusInfo() + + // When: Checking tracking enabled flag + // Then: Should match authorization status + if sut.authorizationStatus == .authorized { + #expect(statusInfo.isTrackingEnabled, "Tracking should be enabled when authorized") + } else { + #expect(!statusInfo.isTrackingEnabled, "Tracking should be disabled when not authorized") + } + } + + @Test("Get status info can request matches status") + func getStatusInfoCanRequestMatchesStatus() { + // Given: Current status + let statusInfo = sut.getStatusInfo() + + // When: Checking can request permission flag + // Then: Should only be true when not determined + if sut.authorizationStatus == .notDetermined { + #expect(statusInfo.canRequestPermission, "Should be able to request when not determined") + } else { + #expect(!statusInfo.canRequestPermission, "Should not be able to request when already decided") + } + } + + // MARK: - Can Show Prompt Tests + + @Test("Can show ATT prompt only when not determined") + func canShowATTPromptOnlyWhenNotDetermined() { + // Given: Current ATT status + let canShow = sut.canShowATTPrompt() + let isNotDetermined = sut.authorizationStatus == .notDetermined + + // When: Checking if we can show ATT prompt + // Then: Should only return true when status is notDetermined + #expect(canShow == isNotDetermined, "Can show prompt should match notDetermined status") + } + + @Test("Can show ATT prompt returns false when authorized") + func canShowATTPromptReturnsFalseWhenAuthorized() { + // Given: Status is authorized (or simulate it) + if sut.authorizationStatus == .authorized { + // When: Checking if can show prompt + let canShow = sut.canShowATTPrompt() + + // Then: Should return false + #expect(!canShow, "Should not show prompt when authorized") + } + } + + // MARK: - Update Status Tests + + @Test("Update current status updates status") + func updateCurrentStatusUpdatesStatus() async { + // Given: ATTManager instance + let initialStatus = sut.authorizationStatus + + // When: Updating current status + sut.updateCurrentStatus() + + // Wait for main thread update + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 second + + // Then: Status should be set (may be same as before) + #expect(sut.authorizationStatus != nil, "Status should be set after update") + } + + // MARK: - History Tests + + @Test("Status history starts empty") + func statusHistoryStartsEmpty() { + // Given: Fresh ATTManager instance + let newManager = ATTManager() + + // Then: Status history should be empty or have at most initial entry + #expect(newManager.statusHistory.count <= 1, "History should start empty or with one entry") + } + + @Test("Reset for testing clears history") + func resetForTestingClearsHistory() async { + // Given: ATTManager with some history + // When: Resetting for testing + sut.resetForTesting() + + // Wait for async operations + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + + // Then: History should be cleared or have only reset entry + #expect(sut.statusHistory.count <= 1, "History should be reset") + } + + // MARK: - Permission Request Tests + + @Test("Request IDFA permission with completion") + func requestIDFAPermissionWithCompletion() async { + // Given: ATTManager + await withCheckedContinuation { continuation in + // When: Requesting permission + sut.requestIDFAPermission { status in + // Then: Completion should be called + #expect(status != nil, "Status should be provided in completion") + continuation.resume() + } + } + } + + // MARK: - Thread Safety Tests + + @Test("Published properties update on main thread") + func publishedPropertiesUpdateOnMainThread() async { + // Given: ATTManager + var isMainThread = false + + // When: Updating status + sut.updateCurrentStatus() + + // Wait for update + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then: Should be on main thread + await MainActor.run { + isMainThread = Thread.isMainThread + } + + #expect(isMainThread, "Updates should happen on main thread") + } + + // MARK: - Edge Cases Tests + + @Test("Multiple update status calls do not crash") + func multipleUpdateStatusCallsDoNotCrash() async { + // Given: ATTManager + // When: Calling updateCurrentStatus multiple times rapidly + for _ in 0..<10 { + sut.updateCurrentStatus() + } + + // Wait for all updates + try? await Task.sleep(nanoseconds: 500_000_000) + + // Then: Should not crash + #expect(sut.authorizationStatus != nil, "Should handle multiple updates") + } + + @Test("Status info consistency") + func statusInfoConsistency() { + // Given: Current status + let info1 = sut.getStatusInfo() + let info2 = sut.getStatusInfo() + + // When: Getting status info multiple times + // Then: Should be consistent + #expect(info1.status == info2.status) + #expect(info1.idfa == info2.idfa) + #expect(info1.isTrackingEnabled == info2.isTrackingEnabled) + #expect(info1.canRequestPermission == info2.canRequestPermission) + } + + // MARK: - iOS Version Compatibility Tests + + @Test("Compatibility with iOS 13") + func compatibilityWithiOS13() { + // Given: Code that checks iOS version + // When: Running on any iOS version + // Then: Should not crash + #expect(sut.authorizationStatus != nil, "Should work on all iOS versions") + } + + // MARK: - Performance Tests + + @Test("Status to string performance", .timeLimit(.minutes(1))) + func statusToStringPerformance() { + // Given: Status + let status: ATTrackingManager.AuthorizationStatus = .authorized + + // When: Converting many times + for _ in 0..<1000 { + _ = sut.statusToString(status) + } + + // Then: Should be fast + } + + @Test("Status to emoji performance", .timeLimit(.minutes(1))) + func statusToEmojiPerformance() { + // Given: Status + let status: ATTrackingManager.AuthorizationStatus = .authorized + + // When: Converting many times + for _ in 0..<1000 { + _ = sut.statusToEmoji(status) + } + + // Then: Should be fast + } + + @Test("Get status info performance", .timeLimit(.minutes(1))) + func getStatusInfoPerformance() { + // When: Getting status info many times + for _ in 0..<100 { + _ = sut.getStatusInfo() + } + + // Then: Should be fast + } + + // MARK: - Integration Tests + + @Test("Status matches system status") + func statusMatchesSystemStatus() { + // Given: System ATT status + if #available(iOS 14, *) { + let systemStatus = ATTrackingManager.trackingAuthorizationStatus + + // When: Getting manager status + let managerStatus = sut.authorizationStatus + + // Then: Should match + #expect(managerStatus == systemStatus, "Manager status should match system status") + } + } + + @Test("IDFA matches system IDFA") + func idfaMatchesSystemIDFA() { + // Given: System IDFA + let systemIDFA = ASIdentifierManager.shared().advertisingIdentifier.uuidString + + // When: Getting manager IDFA + let managerIDFA = sut.idfa + + // Then: Should match (if authorized) or be zeros (if not) + if sut.authorizationStatus == .authorized { + // May match or may be zeros depending on actual permission + #expect(UUID(uuidString: managerIDFA) != nil, "IDFA should be valid UUID") + } else { + #expect(managerIDFA == "00000000-0000-0000-0000-000000000000", "IDFA should be zeros when not authorized") + } + } +} diff --git a/BranchLinkSimulatorUITests/ATTTestViewUITests.swift b/BranchLinkSimulatorUITests/ATTTestViewUITests.swift new file mode 100644 index 0000000..df782e6 --- /dev/null +++ b/BranchLinkSimulatorUITests/ATTTestViewUITests.swift @@ -0,0 +1,440 @@ +// +// ATTTestViewUITests.swift +// BranchLinkSimulatorUITests +// +// UI Tests for ATT Testing functionality +// + +import XCTest + +final class ATTTestViewUITests: XCTestCase { + + var app: XCUIApplication! + + override func setUpWithError() throws { + try super.setUpWithError() + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + override func tearDownWithError() throws { + app = nil + try super.tearDownWithError() + } + + // MARK: - Navigation Tests + + func testNavigateToATTTestingView() throws { + // Given: App is launched and on home screen + XCTAssertTrue(app.navigationBars["Branch Link Simulator"].exists, "Should be on home screen") + + // When: Tapping on ATT Testing link + let attTestingLink = app.buttons["ATT Testing"] + XCTAssertTrue(attTestingLink.waitForExistence(timeout: 5), "ATT Testing link should exist") + attTestingLink.tap() + + // Then: Should navigate to ATT Testing view + XCTAssertTrue(app.navigationBars["ATT Testing"].waitForExistence(timeout: 5), "Should navigate to ATT Testing view") + } + + func testBackNavigationFromATTTestingView() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Tapping back button + let backButton = app.navigationBars.buttons.firstMatch + XCTAssertTrue(backButton.exists, "Back button should exist") + backButton.tap() + + // Then: Should return to home screen + XCTAssertTrue(app.navigationBars["Branch Link Simulator"].waitForExistence(timeout: 5), "Should return to home screen") + } + + // MARK: - UI Elements Existence Tests + + func testCurrentStatusSectionExists() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: View is displayed + // Then: Current ATT Status section should exist + XCTAssertTrue(elementExists("Current ATT Status"), "Current ATT Status header should exist") + XCTAssertTrue(elementExists("Status"), "Status label should exist") + XCTAssertTrue(elementExists("IDFA"), "IDFA label should exist") + XCTAssertTrue(elementExists("Tracking Enabled"), "Tracking Enabled label should exist") + } + + func testActionButtonsExist() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: View is displayed + // Then: Action buttons should exist + XCTAssertTrue(buttonExists(containing: "Request ATT Permission"), "Request ATT Permission button should exist") + XCTAssertTrue(buttonExists(containing: "Refresh Status"), "Refresh Status button should exist") + XCTAssertTrue(buttonExists(containing: "Open App Settings"), "Open App Settings button should exist") + } + + func testStatusHistorySectionExists() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: View is displayed + // Then: Status History section should exist + XCTAssertTrue(elementExists("Status History"), "Status History header should exist") + } + + func testTestScenariosSectionExists() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Scrolling down to test scenarios section + // Scroll to ensure all scenarios are visible + let notDeterminedElement = app.staticTexts["Not Determined"] + scrollToElement(notDeterminedElement) + + // Scroll a bit more to ensure all 4 scenarios are visible + app.swipeUp() + + // Wait for elements to settle + Thread.sleep(forTimeInterval: 0.5) + + // Then: All test scenario elements should exist + XCTAssertTrue(elementExists("Not Determined"), "Not Determined scenario should exist") + XCTAssertTrue(elementExists("Authorized"), "Authorized scenario should exist") + XCTAssertTrue(elementExists("Denied"), "Denied scenario should exist") + XCTAssertTrue(elementExists("Restricted"), "Restricted scenario should exist") + + // Check that the section header is also accessible (even if we didn't scroll to it first) + let headerExists = app.staticTexts["Test Scenarios & Expected Behavior"].exists || + app.otherElements["Test Scenarios & Expected Behavior"].exists + XCTAssertTrue(headerExists, "Test Scenarios header should exist") + } + + func testBranchIntegrationSectionExists() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Scrolling to Branch SDK Integration + let branchHeader = findElement("Branch SDK Integration") + scrollToElement(branchHeader) + + // Then: Branch integration info should exist + XCTAssertTrue(branchHeader.exists, "Branch SDK Integration header should exist") + XCTAssertTrue(elementExists("How Branch Uses ATT"), "How Branch Uses ATT text should exist") + } + + func testTestingNotesSectionExists() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Scrolling to Testing Notes + let notesHeader = findElement("Testing Notes") + scrollToElement(notesHeader) + + // Then: Testing notes should exist + XCTAssertTrue(notesHeader.exists, "Testing Notes header should exist") + XCTAssertTrue(elementExists("⚠️ Important"), "Important warning should exist") + } + + // MARK: - Status Display Tests + + func testStatusValueIsDisplayed() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Checking status display + XCTAssertTrue(elementExists("Status"), "Status label should exist") + + // Then: Status value should be one of valid statuses + let validStatuses = ["Not Determined", "Authorized", "Denied", "Restricted"] + let statusValueExists = validStatuses.contains { status in + elementExists(status) + } + XCTAssertTrue(statusValueExists, "Status value should be one of: \(validStatuses.joined(separator: ", "))") + } + + func testIDFAIsDisplayedInCorrectFormat() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Checking IDFA display + let idfaLabel = findElement("IDFA") + XCTAssertTrue(idfaLabel.exists, "IDFA label should exist") + + // Then: IDFA should be displayed (format validation done in unit tests) + // Just verify it exists and is not empty + XCTAssertTrue(idfaLabel.exists, "IDFA should be displayed") + } + + func testTrackingEnabledStatusIsDisplayed() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Checking tracking enabled status + XCTAssertTrue(elementExists("Tracking Enabled"), "Tracking Enabled label should exist") + + // Then: Should show icon (checkmark or x) + let hasCheckmark = app.images["checkmark.circle.fill"].exists + let hasXmark = app.images["xmark.circle.fill"].exists + XCTAssertTrue(hasCheckmark || hasXmark, "Should show either checkmark or xmark icon") + } + + // MARK: - Button Interaction Tests + + func testRefreshStatusButtonWorks() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Tapping Refresh Status button + let refreshButton = findButton(containing: "Refresh Status") + XCTAssertTrue(refreshButton.exists, "Refresh Status button should exist") + XCTAssertTrue(refreshButton.isEnabled, "Refresh Status button should be enabled") + + refreshButton.tap() + + // Then: View should still be visible (status refreshed) + XCTAssertTrue(app.navigationBars["ATT Testing"].exists, "Should still be on ATT Testing view") + } + + func testRequestPermissionButtonStateMatchesATTStatus() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Checking Request ATT Permission button using accessibility identifier + let requestButton = app.buttons["Request ATT Permission"] + XCTAssertTrue(requestButton.waitForExistence(timeout: 5), "Request ATT Permission button should exist") + + // Then: Button state should match ATT status + // If status is "Not Determined", button should be enabled + // Otherwise, button should be disabled + let isNotDetermined = elementExists("Not Determined") + + if isNotDetermined { + // For SwiftUI buttons with custom styling, isHittable is more reliable than isEnabled + XCTAssertTrue(requestButton.isHittable, "Button should be hittable/enabled when status is Not Determined") + } else { + XCTAssertFalse(requestButton.isEnabled, "Button should be disabled when status is not Not Determined") + } + } + + func testOpenAppSettingsButtonExists() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Finding Open App Settings button + let settingsButton = findButton(containing: "Open App Settings") + + // Then: Button should exist and be enabled + XCTAssertTrue(settingsButton.exists, "Open App Settings button should exist") + XCTAssertTrue(settingsButton.isEnabled, "Open App Settings button should be enabled") + + // Note: We don't tap it as it would leave the app + } + + // MARK: - Status History Tests + + func testStatusHistoryClearButton() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Finding Clear button in Status History + let clearButton = app.buttons["Clear"] + + // Then: Clear button should exist + XCTAssertTrue(clearButton.exists, "Clear button should exist in Status History section") + } + + func testStatusHistoryDisplaysCorrectly() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Checking status history section + XCTAssertTrue(elementExists("Status History"), "Status History header should exist") + + // Then: Should show either "No history yet" or history entries + let noHistory = elementExists("No history yet") + let hasHistory = !noHistory + + if !hasHistory { + XCTAssertTrue(noHistory, "Should show 'No history yet' when empty") + } + } + + // MARK: - Scrolling Tests + + func testCanScrollToBottomOfView() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Scrolling to bottom + let testingNotesHeader = findElement("Testing Notes") + scrollToElement(testingNotesHeader) + + // Then: Should be able to see Testing Notes + XCTAssertTrue(testingNotesHeader.exists, "Should be able to scroll to Testing Notes") + } + + func testAllSectionsAreAccessible() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Scrolling through all sections + let sections = [ + "Current ATT Status", + "Actions", + "Status History", + "Test Scenarios & Expected Behavior", + "Branch SDK Integration", + "Testing Notes" + ] + + // Then: All sections should be accessible + for section in sections { + let element = findElement(section) + scrollToElement(element) + XCTAssertTrue(element.exists, "\(section) should be accessible") + } + } + + // MARK: - Visual State Tests + + func testEmojiDisplaysForStatus() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Checking for emoji in status display + let validEmojis = ["❓", "βœ…", "❌", "πŸ”’"] + + // Then: One of the valid emojis should be displayed + let emojiExists = validEmojis.contains { emoji in + elementExists(emoji) + } + XCTAssertTrue(emojiExists, "Status emoji should be displayed") + } + + func testCurrentStateHighlightedInScenarios() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Scrolling to test scenarios + let scenariosHeader = findElement("Test Scenarios & Expected Behavior") + scrollToElement(scenariosHeader) + + // Then: Current state should be highlighted with "CURRENT" badge + // (This would be visible if status matches one of the scenarios) + // Just verify the scenarios section is visible + XCTAssertTrue(scenariosHeader.exists) + } + + // MARK: - Accessibility Tests + + func testImportantElementsHaveAccessibilityLabels() throws { + // Given: On ATT Testing view + navigateToATTTestingViewHelper() + + // When: Checking accessibility + // Then: Important elements should be accessible + let statusLabel = findElement("Status") + XCTAssertTrue(statusLabel.exists, "Status should be accessible") + XCTAssertTrue(statusLabel.isEnabled, "Status should be enabled for accessibility") + } + + // MARK: - Integration Tests + + func testATTTestingIntegratesWithHomeView() throws { + // Given: Home view is displayed + XCTAssertTrue(app.navigationBars["Branch Link Simulator"].exists, "Should start on home screen") + + // When: Navigating to ATT Testing and back multiple times + for _ in 0..<3 { + // Navigate to ATT Testing + let attLink = app.buttons["ATT Testing"] + attLink.tap() + XCTAssertTrue(app.navigationBars["ATT Testing"].waitForExistence(timeout: 2)) + + // Navigate back + app.navigationBars.buttons.firstMatch.tap() + XCTAssertTrue(app.navigationBars["Branch Link Simulator"].waitForExistence(timeout: 2)) + } + + // Then: Should work consistently + XCTAssertTrue(app.navigationBars["Branch Link Simulator"].exists, "Should be back on home screen") + } + + // MARK: - Performance Tests + + func testATTTestingViewLoadsQuickly() throws { + // Measure the time to navigate to ATT Testing view + measure { + // Navigate to ATT Testing + let attLink = app.buttons["ATT Testing"] + attLink.tap() + + // Wait for view to load + _ = app.navigationBars["ATT Testing"].waitForExistence(timeout: 5) + + // Navigate back for cleanup + app.navigationBars.buttons.firstMatch.tap() + _ = app.navigationBars["Branch Link Simulator"].waitForExistence(timeout: 5) + } + } + + // MARK: - Helper Methods + + private func navigateToATTTestingViewHelper() { + let attTestingLink = app.buttons["ATT Testing"] + if attTestingLink.waitForExistence(timeout: 5) { + attTestingLink.tap() + _ = app.navigationBars["ATT Testing"].waitForExistence(timeout: 5) + } + } + + private func scrollToElement(_ element: XCUIElement, maxSwipes: Int = 10) { + var swipes = 0 + while !element.exists && swipes < maxSwipes { + app.swipeUp() + swipes += 1 + } + + // If element exists but not hittable, try a few more swipes + swipes = 0 + while element.exists && !element.isHittable && swipes < 5 { + app.swipeUp() + swipes += 1 + } + } + + private func elementExists(_ identifier: String) -> Bool { + // Check both staticTexts and other elements with the identifier + return app.staticTexts[identifier].exists || + app.otherElements[identifier].exists || + app.buttons[identifier].exists + } + + private func findElement(_ identifier: String) -> XCUIElement { + // Try to find element in different element types + if app.staticTexts[identifier].exists { + return app.staticTexts[identifier] + } else if app.otherElements[identifier].exists { + return app.otherElements[identifier] + } else if app.buttons[identifier].exists { + return app.buttons[identifier] + } + return app.staticTexts[identifier] // Return staticText as default + } + + private func buttonExists(containing text: String) -> Bool { + return app.buttons.matching(NSPredicate(format: "label CONTAINS %@", text)).firstMatch.exists || + app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", text)).firstMatch.exists + } + + private func findButton(containing text: String) -> XCUIElement { + let button = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", text)).firstMatch + if button.exists { + return button + } + return app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", text)).firstMatch + } +} diff --git a/BranchLinkSimulatorUITests/BranchLinkSimulatorUITests.swift b/BranchLinkSimulatorUITests/BranchLinkSimulatorUITests.swift new file mode 100644 index 0000000..bd9dacd --- /dev/null +++ b/BranchLinkSimulatorUITests/BranchLinkSimulatorUITests.swift @@ -0,0 +1,41 @@ +// +// BranchLinkSimulatorUITests.swift +// BranchLinkSimulatorUITests +// +// Created by Willian Pinho on 10/11/25. +// + +import XCTest + +final class BranchLinkSimulatorUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/BranchLinkSimulatorUITests/BranchLinkSimulatorUITestsLaunchTests.swift b/BranchLinkSimulatorUITests/BranchLinkSimulatorUITestsLaunchTests.swift new file mode 100644 index 0000000..272f51f --- /dev/null +++ b/BranchLinkSimulatorUITests/BranchLinkSimulatorUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// BranchLinkSimulatorUITestsLaunchTests.swift +// BranchLinkSimulatorUITests +// +// Created by Willian Pinho on 10/11/25. +// + +import XCTest + +final class BranchLinkSimulatorUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/IPA/Info.plist b/IPA/Info.plist new file mode 100644 index 0000000..12b4d56 --- /dev/null +++ b/IPA/Info.plist @@ -0,0 +1,25 @@ + + + + + destination + export + method + release-testing + provisioningProfiles + + io.branch.link-simulator + Branch Link Simulator - Distribution + + signingCertificate + Apple Distribution + signingStyle + manual + stripSwiftSymbols + + teamID + R63EM248DP + thinning + <none> + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5cf9220 --- /dev/null +++ b/Package.swift @@ -0,0 +1,57 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "BranchLinkSimulator", + platforms: [ + .iOS(.v17) + ], + products: [ + // Library product for the main app module + .library( + name: "BranchLinkSimulatorLib", + targets: ["BranchLinkSimulatorLib"] + ) + ], + dependencies: [ + // BranchSDK dependency from GitHub + .package( + url: "https://github.com/BranchMetrics/ios-branch-deep-linking-attribution", + branch: "master" + ) + ], + targets: [ + // Main library target containing app source code + .target( + name: "BranchLinkSimulatorLib", + dependencies: [ + .product(name: "BranchSDK", package: "ios-branch-deep-linking-attribution") + ], + path: "BranchLinkSimulator", + exclude: [ + "Info.plist", + "BranchLinkSimulator.entitlements", + "Assets.xcassets", + "Preview Content" + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + + // Unit test target with Swift Testing framework + .testTarget( + name: "BranchLinkSimulatorTests", + dependencies: [ + "BranchLinkSimulatorLib", + .product(name: "BranchSDK", package: "ios-branch-deep-linking-attribution") + ], + path: "BranchLinkSimulatorTests", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ) + ] +)