From 2e43fffb621b22f30281a139e3a88adfce63df87 Mon Sep 17 00:00:00 2001 From: ilitvinenko Date: Tue, 25 Jun 2024 19:21:09 +0300 Subject: [PATCH 1/3] MOBIL-4532: Make it possible to do cert pinning on iOS (#3137) --- Airship/Airship.xcodeproj/project.pbxproj | 16 ++ .../Assets/DefaultAssetDownloader.swift | 2 +- .../View/InAppMessageWebView.swift | 14 +- .../Source/InAppMessage/View/MediaView.swift | 17 +++ Airship/AirshipCore/Source/Airship.swift | 2 + .../Source/AirshipImageLoader.swift | 2 +- .../Source/AirshipRequestSession.swift | 13 +- .../AirshipCore/Source/AirshipWebview.swift | 13 +- .../Source/ChallengeResolver.swift | 50 +++++++ Airship/AirshipCore/Source/Config.swift | 5 + Airship/AirshipCore/Source/MediaWebView.swift | 23 ++- Airship/AirshipCore/Source/NativeBridge.swift | 10 +- .../Tests/ChallengeResolverTest.swift | 141 ++++++++++++++++++ Airship/AirshipCore/Tests/Support/airship.der | Bin 0 -> 1780 bytes .../Views/MessageCenterMessageView.swift | 12 +- .../project.pbxproj | 4 + .../Source/ChallengeResolver.swift | 50 +++++++ .../UANotificationServiceExtension.swift | 9 +- 18 files changed, 367 insertions(+), 16 deletions(-) create mode 100644 Airship/AirshipCore/Source/ChallengeResolver.swift create mode 100644 Airship/AirshipCore/Tests/ChallengeResolverTest.swift create mode 100644 Airship/AirshipCore/Tests/Support/airship.der create mode 100644 AirshipExtensions/AirshipNotificationServiceExtension/Source/ChallengeResolver.swift diff --git a/Airship/Airship.xcodeproj/project.pbxproj b/Airship/Airship.xcodeproj/project.pbxproj index 25305e516..b90c4e57f 100644 --- a/Airship/Airship.xcodeproj/project.pbxproj +++ b/Airship/Airship.xcodeproj/project.pbxproj @@ -156,6 +156,11 @@ 3CC95B2C2696549B00FE2ACD /* AirshipPushableComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC95B2A2696549B00FE2ACD /* AirshipPushableComponent.swift */; }; 45A8ADF023134B38004AD8CA /* testMCColorsCatalog.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 45A8ADD123133E51004AD8CA /* testMCColorsCatalog.xcassets */; }; 54DE2901247B2AF059E46862 /* Pods_AirshipTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EBF83042659DAF0D42AD7A9E /* Pods_AirshipTests.framework */; }; + 6014AD672C1B5F540072DCF0 /* ChallengeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6014AD662C1B5F540072DCF0 /* ChallengeResolver.swift */; }; + 6014AD6C2C2032730072DCF0 /* ChallengeResolverTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6014AD6A2C2032360072DCF0 /* ChallengeResolverTest.swift */; }; + 6014AD752C20410B0072DCF0 /* airship.der in Resources */ = {isa = PBXBuildFile; fileRef = 6014AD742C20410A0072DCF0 /* airship.der */; }; + 6014AD762C20492E0072DCF0 /* ChallengeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6014AD662C1B5F540072DCF0 /* ChallengeResolver.swift */; }; + 6014AD772C2049300072DCF0 /* ChallengeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6014AD662C1B5F540072DCF0 /* ChallengeResolver.swift */; }; 6018AF572B29C20A008E528B /* SearchEventTemplateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6018AF562B29C20A008E528B /* SearchEventTemplateTest.swift */; }; 603269532BF4B141007F7F75 /* AdditionalAudienceCheckerApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603269522BF4B141007F7F75 /* AdditionalAudienceCheckerApiClient.swift */; }; 603269552BF4B8D5007F7F75 /* AdditionalAudienceCheckerResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603269542BF4B8D5007F7F75 /* AdditionalAudienceCheckerResolver.swift */; }; @@ -2023,6 +2028,9 @@ 45F651ACE8833830E1D2333D /* Pods_AirshipAccengageTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AirshipAccengageTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 494DD9571B0EB677009C134E /* AirshipCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 494DD95B1B0EB677009C134E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6014AD662C1B5F540072DCF0 /* ChallengeResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeResolver.swift; sourceTree = ""; }; + 6014AD6A2C2032360072DCF0 /* ChallengeResolverTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeResolverTest.swift; sourceTree = ""; }; + 6014AD742C20410A0072DCF0 /* airship.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = airship.der; sourceTree = ""; }; 6018AF562B29C20A008E528B /* SearchEventTemplateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchEventTemplateTest.swift; sourceTree = ""; }; 603269522BF4B141007F7F75 /* AdditionalAudienceCheckerApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionalAudienceCheckerApiClient.swift; sourceTree = ""; }; 603269542BF4B8D5007F7F75 /* AdditionalAudienceCheckerResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionalAudienceCheckerResolver.swift; sourceTree = ""; }; @@ -3992,6 +4000,7 @@ 6E299FD628D13E54001305A7 /* AirshipRequest.swift */, 6E299FDA28D14208001305A7 /* AirshipResponse.swift */, 6E299FDE28D14258001305A7 /* AirshipRequestSession.swift */, + 6014AD662C1B5F540072DCF0 /* ChallengeResolver.swift */, ); name = HTTP; sourceTree = ""; @@ -5125,6 +5134,7 @@ CC64F1421D8B7954009CEF27 /* Support */ = { isa = PBXGroup; children = ( + 6014AD742C20410A0072DCF0 /* airship.der */, CC64F0611D8B781C009CEF27 /* CustomNotificationCategories.plist */, CC64F0621D8B781C009CEF27 /* Info.plist */, CC64F1431D8B7954009CEF27 /* AirshipConfig-DetectProvisioningMode.plist */, @@ -5162,6 +5172,7 @@ isa = PBXGroup; children = ( 6E299FD428D13D00001305A7 /* DefaultAirshipRequestSessionTest.swift */, + 6014AD6A2C2032360072DCF0 /* ChallengeResolverTest.swift */, ); name = HTTP; sourceTree = ""; @@ -5935,6 +5946,7 @@ CC64F14C1D8B7954009CEF27 /* AirshipConfig-InProduction.plist in Resources */, 45A8ADF023134B38004AD8CA /* testMCColorsCatalog.xcassets in Resources */, CC64F1501D8B7954009CEF27 /* AirshipConfig-Without-InProduction-And-DetectProvisioningMode.plist in Resources */, + 6014AD752C20410B0072DCF0 /* airship.der in Resources */, CC64F14E1D8B7954009CEF27 /* AirshipConfig-Valid-NeXTStep.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -6134,6 +6146,7 @@ A6D6D49F2A02608C0072A5CA /* ActionResult.swift in Sources */, 6E91B43C26868A6300DDB1A8 /* CircularRegion.swift in Sources */, 6E1589582AFF023400954A04 /* SessionEvent.swift in Sources */, + 6014AD672C1B5F540072DCF0 /* ChallengeResolver.swift in Sources */, 6EFD6D89272A53FB005B26F1 /* PagerIndicator.swift in Sources */, 6E6C3F9E27A4C3D4007F55C7 /* AirshipJSON.swift in Sources */, 6E49D7C828401D2E00C7BB9D /* APNSRegistrar.swift in Sources */, @@ -6859,6 +6872,7 @@ A6722A9E281A9EDA0033F54D /* TagGroupUpdate.swift in Sources */, 6E4325F42B7B1EDA00A9B000 /* SessionEventFactory.swift in Sources */, A6722A9F281A9EDA0033F54D /* TagGroupsEditor.swift in Sources */, + 6014AD772C2049300072DCF0 /* ChallengeResolver.swift in Sources */, 99C3CC7B2BCF3FA300B5BED5 /* SMSValidator.swift in Sources */, A6722AA0281A9EDA0033F54D /* TagGroupMutations.swift in Sources */, A6722AA1281A9EDA0033F54D /* AirshipLocalizationUtils.swift in Sources */, @@ -7248,6 +7262,7 @@ A63A567628F449D8004B8951 /* TestWorkManager.swift in Sources */, 6EFAFB8C29562866008AD187 /* RemoveTagsActionTest.swift in Sources */, 6E87BD9F26DDDB250005D20D /* AppIntegrationTests.swift in Sources */, + 6014AD6C2C2032730072DCF0 /* ChallengeResolverTest.swift in Sources */, 6E2D6AF626B0C6CA00B7C226 /* TestSubscriptionListAPIClient.swift in Sources */, 6E0B8760294CE0BF0064B7BD /* FarmHashFingerprint64Test.swift in Sources */, 3299EF172948CBC100251E70 /* RemoteDataAPIClientTest.swift in Sources */, @@ -7330,6 +7345,7 @@ A6D6D4A62A02BB200072A5CA /* ActionResult.swift in Sources */, 6E664BDA26C4CD8700A2C8E5 /* PasteboardAction.swift in Sources */, 6E1589592AFF023400954A04 /* SessionEvent.swift in Sources */, + 6014AD762C20492E0072DCF0 /* ChallengeResolver.swift in Sources */, 6EC755A72A4F8FBB00851ABB /* DeviceAudienceSelector.swift in Sources */, 6E6C3F9F27A4C3D4007F55C7 /* AirshipJSON.swift in Sources */, 6E49D7C928401D2E00C7BB9D /* APNSRegistrar.swift in Sources */, diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Assets/DefaultAssetDownloader.swift b/Airship/AirshipAutomation/Source/InAppMessage/Assets/DefaultAssetDownloader.swift index 429fc308c..eb5a799df 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Assets/DefaultAssetDownloader.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Assets/DefaultAssetDownloader.swift @@ -29,7 +29,7 @@ extension URLSession: AssetDownloaderSession { struct DefaultAssetDownloader : AssetDownloader { var session: AssetDownloaderSession - init(session: AssetDownloaderSession = URLSession.shared) { + init(session: AssetDownloaderSession = URLSession.airshipSecureSession) { self.session = session } diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageWebView.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageWebView.swift index a7a5f4d89..37b1c8cb5 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageWebView.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageWebView.swift @@ -94,12 +94,16 @@ struct WKWebViewRepresentable: UIViewRepresentable { private let parent: WKWebViewRepresentable + private let challengeResolver: ChallengeResolver let nativeBridge: NativeBridge - init(_ parent: WKWebViewRepresentable, actionRunner: NativeBridgeActionRunner) { + init(_ parent: WKWebViewRepresentable, actionRunner: NativeBridgeActionRunner, resolver: ChallengeResolver = .shared) { self.parent = parent self.nativeBridge = NativeBridge(actionRunner: actionRunner) + self.challengeResolver = resolver + super.init() + self.nativeBridge.nativeBridgeExtensionDelegate = self.parent.nativeBridgeExtension self.nativeBridge.forwardNavigationDelegate = self @@ -132,6 +136,14 @@ struct WKWebViewRepresentable: UIViewRepresentable { webView?.reload() } } + + func webView( + _ webView: WKWebView, + respondTo challenge: URLAuthenticationChallenge) + async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + + return await challengeResolver.resolve(challenge) + } func performCommand(_ command: JavaScriptCommand, webView: WKWebView) -> Bool { return false diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/MediaView.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/MediaView.swift index a1be374b4..fe0dfedff 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/MediaView.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/MediaView.swift @@ -52,6 +52,7 @@ struct InAppMessageMediaWebView: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let webView = WKWebView() webView.scrollView.isScrollEnabled = false + webView.navigationDelegate = context.coordinator if #available(iOS 16.4, *) { webView.isInspectable = Airship.isFlying && Airship.config.isWebViewInspectionEnabled @@ -75,6 +76,22 @@ struct InAppMessageMediaWebView: UIViewRepresentable { break // Do nothing for images } } + + func makeCoordinator() -> Coordinator { + return Coordinator() + } + + class Coordinator: NSObject, WKNavigationDelegate { + let challengeResolver: ChallengeResolver + + init(resolver: ChallengeResolver = .shared) { + self.challengeResolver = resolver + } + + func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + return await challengeResolver.resolve(challenge) + } + } } struct MediaInfo { diff --git a/Airship/AirshipCore/Source/Airship.swift b/Airship/AirshipCore/Source/Airship.swift index 00574615d..7f3dfd6e0 100644 --- a/Airship/AirshipCore/Source/Airship.swift +++ b/Airship/AirshipCore/Source/Airship.swift @@ -296,6 +296,8 @@ public class Airship: NSObject { private class func commonTakeOff(_ config: AirshipConfig?, onReady: (() -> Void)? = nil) { let resolvedConfig = config?.copy() as? AirshipConfig ?? AirshipConfig.default() + + ChallengeResolver.shared.resolver = resolvedConfig.connectionChallengeResolver self.logLevel = resolvedConfig.logLevel self.logPrivacyLevel = resolvedConfig.logPrivacyLevel diff --git a/Airship/AirshipCore/Source/AirshipImageLoader.swift b/Airship/AirshipCore/Source/AirshipImageLoader.swift index 7d4964183..977a32939 100644 --- a/Airship/AirshipCore/Source/AirshipImageLoader.swift +++ b/Airship/AirshipCore/Source/AirshipImageLoader.swift @@ -33,7 +33,7 @@ public struct AirshipImageLoader { } private func fetchImage(url: URL) -> AnyPublisher { - return URLSession.shared.dataTaskPublisher(for: url) + return URLSession.airshipSecureSession.dataTaskPublisher(for: url) .mapError { AirshipErrors.error("URL error \($0)") } .map { response -> AnyPublisher in guard let httpResponse = response.response as? HTTPURLResponse, diff --git a/Airship/AirshipCore/Source/AirshipRequestSession.swift b/Airship/AirshipCore/Source/AirshipRequestSession.swift index 1c99560d4..af02d7ad6 100644 --- a/Airship/AirshipCore/Source/AirshipRequestSession.swift +++ b/Airship/AirshipCore/Source/AirshipRequestSession.swift @@ -75,7 +75,7 @@ final class DefaultAirshipRequestSession: AirshipRequestSession, @unchecked Send sessionConfig.tlsMinimumSupportedProtocolVersion = .TLSv12 return URLSession( configuration: sessionConfig, - delegate: nil, + delegate: ChallengeResolver.shared, delegateQueue: nil ) }() @@ -482,5 +482,16 @@ extension URLSession: URLRequestSessionProtocol { } } +/** + * URLSession with configured optional challenge resolver + * @note For internal use only. :nodoc: + */ +public extension URLSession { + static let airshipSecureSession: URLSession = .init( + configuration: .default, + delegate: ChallengeResolver.shared, + delegateQueue: nil) +} + diff --git a/Airship/AirshipCore/Source/AirshipWebview.swift b/Airship/AirshipCore/Source/AirshipWebview.swift index 43dee33ae..bdf3ac019 100644 --- a/Airship/AirshipCore/Source/AirshipWebview.swift +++ b/Airship/AirshipCore/Source/AirshipWebview.swift @@ -88,12 +88,16 @@ struct WebViewView: UIViewRepresentable { JavaScriptCommandDelegate, NativeBridgeDelegate { private let parent: WebViewView + private let challengeResolver: ChallengeResolver let nativeBridge: NativeBridge - init(_ parent: WebViewView, actionRunner: NativeBridgeActionRunner) { + init(_ parent: WebViewView, actionRunner: NativeBridgeActionRunner, resolver: ChallengeResolver = .shared) { self.parent = parent self.nativeBridge = NativeBridge(actionRunner: actionRunner) + self.challengeResolver = resolver + super.init() + self.nativeBridge.nativeBridgeExtensionDelegate = self.parent.nativeBridgeExtension self.nativeBridge.forwardNavigationDelegate = self @@ -126,6 +130,13 @@ struct WebViewView: UIViewRepresentable { webView?.reload() } } + + func webView( + _ webView: WKWebView, + respondTo challenge: URLAuthenticationChallenge) + async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + return await challengeResolver.resolve(challenge) + } func performCommand(_ command: JavaScriptCommand, webView: WKWebView) -> Bool { return false diff --git a/Airship/AirshipCore/Source/ChallengeResolver.swift b/Airship/AirshipCore/Source/ChallengeResolver.swift new file mode 100644 index 000000000..fac13af3d --- /dev/null +++ b/Airship/AirshipCore/Source/ChallengeResolver.swift @@ -0,0 +1,50 @@ +/* Copyright Airship and Contributors */ + +import Foundation + +/** + * Authentication challenge resolver class + * @note For internal use only. :nodoc: + */ +public class ChallengeResolver: NSObject, @unchecked Sendable { + + public static let shared = ChallengeResolver() + + @MainActor + var resolver: ChallengeResolveClosure? + + private override init() {} + + public func resolve(_ challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard + let resolver = await self.resolver, + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + challenge.protectionSpace.serverTrust != nil + else { + return (.performDefaultHandling, nil) + } + + return resolver(challenge) + } +} + +extension ChallengeResolver: URLSessionTaskDelegate { + + public func urlSession(_ session: URLSession, + didReceive challenge: URLAuthenticationChallenge) + async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + + return await self.resolve(challenge) + } + + public func urlSession(_ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge) + async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + return await self.resolve(challenge) + } + +} + + +public typealias ChallengeResolveClosure = @Sendable (URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) diff --git a/Airship/AirshipCore/Source/Config.swift b/Airship/AirshipCore/Source/Config.swift index c2eae28cf..20d3e2a38 100644 --- a/Airship/AirshipCore/Source/Config.swift +++ b/Airship/AirshipCore/Source/Config.swift @@ -54,6 +54,10 @@ public class AirshipConfig: NSObject, NSCopying { /// Defaults to `false` @objc public var isWebViewInspectionEnabled: Bool = false + + /// Allows setting a custom closure for auth challenge certificate validation + /// Defaults to `nil` + public var connectionChallengeResolver: ChallengeResolveClosure? /// The airship cloud site. Defaults to `us`. @objc @@ -475,6 +479,7 @@ public class AirshipConfig: NSObject, NSCopying { useUserPreferredLocale = config.useUserPreferredLocale restoreMessageCenterOnReinstall = config.restoreMessageCenterOnReinstall isWebViewInspectionEnabled = config.isWebViewInspectionEnabled + connectionChallengeResolver = config.connectionChallengeResolver } public func copy(with zone: NSZone? = nil) -> Any { diff --git a/Airship/AirshipCore/Source/MediaWebView.swift b/Airship/AirshipCore/Source/MediaWebView.swift index 535978327..3b010ac37 100644 --- a/Airship/AirshipCore/Source/MediaWebView.swift +++ b/Airship/AirshipCore/Source/MediaWebView.swift @@ -216,17 +216,28 @@ struct MediaWebView: UIViewRepresentable { var parent: MediaWebView var isLoaded: Binding var onMediaReady: @MainActor () -> Void - - init(_ parent: MediaWebView, isLoaded: Binding, onMediaReady: @escaping @MainActor () -> Void) { - self.parent = parent - self.isLoaded = isLoaded - self.onMediaReady = onMediaReady - } + let challengeResolver: ChallengeResolver + + init( + _ parent: MediaWebView, + isLoaded: Binding, + resolver: ChallengeResolver = .shared, + onMediaReady: @escaping @MainActor () -> Void) { + + self.parent = parent + self.isLoaded = isLoaded + self.onMediaReady = onMediaReady + self.challengeResolver = resolver + } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { isLoaded.wrappedValue = true } + func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + return await challengeResolver.resolve(challenge) + } + @MainActor func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let response = message.body as? String else { diff --git a/Airship/AirshipCore/Source/NativeBridge.swift b/Airship/AirshipCore/Source/NativeBridge.swift index 7c65a9e24..06ca904d8 100644 --- a/Airship/AirshipCore/Source/NativeBridge.swift +++ b/Airship/AirshipCore/Source/NativeBridge.swift @@ -49,6 +49,7 @@ public class NativeBridge: NSObject, WKNavigationDelegate { private let actionHandler: NativeBridgeActionHandlerProtocol private let javaScriptEnvironmentFactoryBlock: () -> JavaScriptEnvironmentProtocol + private let challengeResolver: ChallengeResolver /// NativeBridge initializer. /// - Note: For internal use only. :nodoc: @@ -56,11 +57,13 @@ public class NativeBridge: NSObject, WKNavigationDelegate { /// - Parameter javaScriptEnvironmentFactoryBlock: A factory block producing a JavaScript environment. init( actionHandler: NativeBridgeActionHandlerProtocol, - javaScriptEnvironmentFactoryBlock: @escaping () -> JavaScriptEnvironmentProtocol + javaScriptEnvironmentFactoryBlock: @escaping () -> JavaScriptEnvironmentProtocol, + resolver: ChallengeResolver = .shared ) { self.actionHandler = actionHandler self.javaScriptEnvironmentFactoryBlock = javaScriptEnvironmentFactoryBlock + self.challengeResolver = resolver super.init() } @@ -349,7 +352,10 @@ public class NativeBridge: NSObject, WKNavigationDelegate { ) -> Void )? else { - completionHandler(.performDefaultHandling, nil) + Task { + let (disposition, creds) = await challengeResolver.resolve(challenge) + completionHandler(disposition, creds) + } return } diff --git a/Airship/AirshipCore/Tests/ChallengeResolverTest.swift b/Airship/AirshipCore/Tests/ChallengeResolverTest.swift new file mode 100644 index 000000000..49015b84d --- /dev/null +++ b/Airship/AirshipCore/Tests/ChallengeResolverTest.swift @@ -0,0 +1,141 @@ +/* Copyright Airship and Contributors */ + +import XCTest +@testable import AirshipCore + +final class ChallengeResolverTest: XCTestCase { + + @MainActor + override func tearDown() async throws { + ChallengeResolver.shared.resolver = nil + } + + func testResolverReturnsDefaultIfNotConfigured() async { + await assertResolve(disposition: .performDefaultHandling, credentials: nil) + } + + @MainActor + func testResolverClosure() async { + let credentials = URLCredential() + ChallengeResolver.shared.resolver = { _ in + return (URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, credentials) + } + + let challenge = URLAuthenticationChallenge( + protectionSpace: AirshipProtectionSpace( + host: "urbanairship.com", + port: 443, + protocol: "https", + realm: nil, + authenticationMethod: NSURLAuthenticationMethodServerTrust), + proposedCredential: nil, + previousFailureCount: 0, + failureResponse: nil, + error: nil, + sender: ChallengeSender()) + + await assertResolve( + disposition: .cancelAuthenticationChallenge, + credentials: credentials, + challenge: challenge + ) + } + + @MainActor + func testResolverClosureNotCalledOnNonServerTrust() async { + let credentials = URLCredential() + ChallengeResolver.shared.resolver = { _ in + return (URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, credentials) + } + + let challenge = URLAuthenticationChallenge( + protectionSpace: AirshipProtectionSpace( + host: "urbanairship.com", + port: 443, + protocol: "https", + realm: nil, + authenticationMethod: NSURLAuthenticationMethodClientCertificate), + proposedCredential: nil, + previousFailureCount: 0, + failureResponse: nil, + error: nil, + sender: ChallengeSender()) + + await assertResolve( + disposition: .performDefaultHandling, + credentials: nil, + challenge: challenge + ) + } + + @MainActor + func testResolverClosureNotCalledOnNoPublicKey() async { + let credentials = URLCredential() + ChallengeResolver.shared.resolver = { _ in + return (URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, credentials) + } + + let protectionSpace = AirshipProtectionSpace( + host: "urbanairship.com", + port: 443, + protocol: "https", + realm: nil, + authenticationMethod: NSURLAuthenticationMethodServerTrust) + protectionSpace.useAirshipCert = false + + let challenge = URLAuthenticationChallenge( + protectionSpace: protectionSpace, + proposedCredential: nil, + previousFailureCount: 0, + failureResponse: nil, + error: nil, + sender: ChallengeSender()) + + await assertResolve( + disposition: .performDefaultHandling, + credentials: nil, + challenge: challenge + ) + } + + private func assertResolve( + disposition: URLSession.AuthChallengeDisposition, + credentials: URLCredential? = nil, + challenge: URLAuthenticationChallenge? = nil + ) async { + let actual = await ChallengeResolver.shared.resolve(challenge ?? URLAuthenticationChallenge()) + + XCTAssertEqual(disposition, actual.0) + XCTAssertEqual(credentials, actual.1) + } +} + +private class ChallengeSender: NSObject, URLAuthenticationChallengeSender { + func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) { } + + func continueWithoutCredential(for challenge: URLAuthenticationChallenge) { } + + func cancel(_ challenge: URLAuthenticationChallenge) { } +} + +private class AirshipProtectionSpace: URLProtectionSpace { + var useAirshipCert: Bool = true + + private func airshipCert() -> SecTrust? { + guard + let certFilePath = Bundle(for: type(of: self)).path(forResource: "airship", ofType: "der"), + let data = NSData(contentsOfFile: certFilePath), + let cert = SecCertificateCreateWithData(nil, data) + else { + return nil + } + + var trust: SecTrust? + SecTrustCreateWithCertificates(cert, SecPolicyCreateBasicX509(), &trust) + return trust + } + + override var serverTrust: SecTrust? { + return useAirshipCert ? airshipCert() : nil + } +} diff --git a/Airship/AirshipCore/Tests/Support/airship.der b/Airship/AirshipCore/Tests/Support/airship.der new file mode 100644 index 0000000000000000000000000000000000000000..46afc9fcbfbc5477d04d19e886db2fb5de5f4b87 GIT binary patch literal 1780 zcmXqLV*6mw#Cl@^GZP~dlK>-M<2SCOE2atlTl)3h($jVZylk9WZ60mkc^MhGSs4r> z4Y>_C*_cCF*o2uvgAGLugh3oGVIG&v^i1c}q7ntqyktXT14EE7v#=Ii*gYpdDKSUE z-AEzCCs-jU*ij+a!_mmpOu@*&$Uwo_(a=CnoY%|3+rx+tH*;?ogumHs@pj$ApV~Z{MtQZ$XG*p|UU})4M(>5y zPriIT^h?_*)@Dul(`JUX7M~KNIQ8>2nb_-|d0&+~vEuD9w_{dpIjX;Z@Gy?^sUX5P{MY?3Q+YD1^rUdJVGpMK0& z$U9%SVXp+YdG-^Rxb7`mJ-+R`IC1%Z7mmgo_nT^c^y1!qUZQuTX1AP|=Q&5F*Sy!7 zy%&prI=YVK_o;6@YKy~f&SYX{WMEv}#5~`iiFvkxJTM?-m02VV#2Q3OS{n|e9lvjW z@BCg(^Ht2U``(@vH;@G>;A0VE5xLFfVe*;n8N)H%#;YGEgo_y+sLnOe2T2REXc(w9 zVTp_;0gUM318LxAWc<&iuv;0vA_fiNw1*f3QVoK zhI&AKm?oK!YmyzvfeI|3?VG zBO^<-L8XBTjBmi$CIQu%pIlskqSYV}u8OJ66&4_gppYm=QK(OBn3RBv2U%ejCIe7r zX<*|5Mh6SywMr%yCYC0~0wB&|%wi~Gcz5;(N{ z#o&tq^S?7GGTc7&_fX_vR@QTEX>&**wSjUcZscm$MxTGb{-7=}7Bd^pZ z2Cneso}Lnw;2Doxhye?5U?IlHuYQuCnf>@f zdi?a*XAbFKW|3yEyFY5o{g4;6to!;64YB;yOI;oqwwrCt(^`MX;@rdUvK3R;KWmbB zsHG@%&#w3AiFJ$Lawx9QJ~Kyc@2{hm71@^Ti!My5nkVLbrYT=#V(QW7`{V5c+M6!1 z?{OFZwC=y5sp=v2TTyrK9FkgY#pE%!*OT|ygQ?=VOrqaSGn}eQ&o@h)XpGO_dGv&! k{=EH*PW^b>n-Xtfu`6NT!?jY{X78 (URLSession.AuthChallengeDisposition, URLCredential?) { + + return await challengeResolver.resolve(challenge) + } func performCommand(_ command: JavaScriptCommand, webView: WKWebView) -> Bool { return false diff --git a/AirshipExtensions/AirshipExtensions.xcodeproj/project.pbxproj b/AirshipExtensions/AirshipExtensions.xcodeproj/project.pbxproj index 3275d89e4..eda8f8205 100644 --- a/AirshipExtensions/AirshipExtensions.xcodeproj/project.pbxproj +++ b/AirshipExtensions/AirshipExtensions.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 1BFF18D0238551FD00013FB9 /* UACarousel.m in Sources */ = {isa = PBXBuildFile; fileRef = 1BFF18CC238551FD00013FB9 /* UACarousel.m */; }; 1BFF18D42385520500013FB9 /* UAContentExtensionViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 1BFF18D12385520500013FB9 /* UAContentExtensionViewController.m */; }; 5872C23EAB3330171E1EBDC3 /* Pods_AirshipNotificationServiceExtensionTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 32B945B8885FAC3BD0955B04 /* Pods_AirshipNotificationServiceExtensionTests.framework */; }; + 6014AD692C1CB6360072DCF0 /* ChallengeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6014AD682C1CB6360072DCF0 /* ChallengeResolver.swift */; }; 60F8E7522B88AF8D00460EDF /* MediaAttachmentPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E7512B88AF8D00460EDF /* MediaAttachmentPayload.swift */; }; 60F8E7542B89272300460EDF /* UANotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E7532B89272300460EDF /* UANotificationServiceExtension.swift */; }; 60F8E7562B8D11B300460EDF /* MediaAttachmentPayloadTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E7552B8D11B300460EDF /* MediaAttachmentPayloadTest.swift */; }; @@ -62,6 +63,7 @@ 49AA00FC1D65158C0081989A /* AirshipNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 49AA01001D65158C0081989A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 49AA010C1D65158C0081989A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6014AD682C1CB6360072DCF0 /* ChallengeResolver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChallengeResolver.swift; sourceTree = ""; }; 60F8E7512B88AF8D00460EDF /* MediaAttachmentPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAttachmentPayload.swift; sourceTree = ""; }; 60F8E7532B89272300460EDF /* UANotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UANotificationServiceExtension.swift; sourceTree = ""; }; 60F8E7552B8D11B300460EDF /* MediaAttachmentPayloadTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAttachmentPayloadTest.swift; sourceTree = ""; }; @@ -227,6 +229,7 @@ 6E21736A237CCEDA0084933A /* Source */ = { isa = PBXGroup; children = ( + 6014AD682C1CB6360072DCF0 /* ChallengeResolver.swift */, 60F8E7512B88AF8D00460EDF /* MediaAttachmentPayload.swift */, 60F8E7532B89272300460EDF /* UANotificationServiceExtension.swift */, 60F8E7592B8E5A0700460EDF /* AirshipNotificationServiceExtension.h */, @@ -493,6 +496,7 @@ files = ( 60F8E7522B88AF8D00460EDF /* MediaAttachmentPayload.swift in Sources */, 60F8E7542B89272300460EDF /* UANotificationServiceExtension.swift in Sources */, + 6014AD692C1CB6360072DCF0 /* ChallengeResolver.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/AirshipExtensions/AirshipNotificationServiceExtension/Source/ChallengeResolver.swift b/AirshipExtensions/AirshipNotificationServiceExtension/Source/ChallengeResolver.swift new file mode 100644 index 000000000..fac13af3d --- /dev/null +++ b/AirshipExtensions/AirshipNotificationServiceExtension/Source/ChallengeResolver.swift @@ -0,0 +1,50 @@ +/* Copyright Airship and Contributors */ + +import Foundation + +/** + * Authentication challenge resolver class + * @note For internal use only. :nodoc: + */ +public class ChallengeResolver: NSObject, @unchecked Sendable { + + public static let shared = ChallengeResolver() + + @MainActor + var resolver: ChallengeResolveClosure? + + private override init() {} + + public func resolve(_ challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard + let resolver = await self.resolver, + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + challenge.protectionSpace.serverTrust != nil + else { + return (.performDefaultHandling, nil) + } + + return resolver(challenge) + } +} + +extension ChallengeResolver: URLSessionTaskDelegate { + + public func urlSession(_ session: URLSession, + didReceive challenge: URLAuthenticationChallenge) + async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + + return await self.resolve(challenge) + } + + public func urlSession(_ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge) + async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + return await self.resolve(challenge) + } + +} + + +public typealias ChallengeResolveClosure = @Sendable (URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) diff --git a/AirshipExtensions/AirshipNotificationServiceExtension/Source/UANotificationServiceExtension.swift b/AirshipExtensions/AirshipNotificationServiceExtension/Source/UANotificationServiceExtension.swift index 638fdde39..36453019c 100644 --- a/AirshipExtensions/AirshipNotificationServiceExtension/Source/UANotificationServiceExtension.swift +++ b/AirshipExtensions/AirshipNotificationServiceExtension/Source/UANotificationServiceExtension.swift @@ -166,10 +166,15 @@ open class UANotificationServiceExtension: UNNotificationServiceExtension { } private func download(url: URL) async throws -> (URL, URLResponse) { + let session = URLSession( + configuration: .default, + delegate: ChallengeResolver.shared, + delegateQueue: nil) + if #available(macOS 12.0, iOS 15.0, watchOS 8.0, *) { - return try await URLSession.shared.download(from: url) + return try await session.download(from: url) } else { - let (data, response) = try await URLSession.shared.data(from: url) + let (data, response) = try await session.data(from: url) let tmpUrl = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try data.write(to: tmpUrl) return (tmpUrl, response) From 44ef217e8852ba102328dfd708a47955ce57248a Mon Sep 17 00:00:00 2001 From: ilitvinenko Date: Sat, 29 Jun 2024 00:25:54 +0300 Subject: [PATCH 2/3] MOBILE-4173: Add an AsyncStream that exposes notificationStatusUpdates on AirshipPushProtocol (#3138) * Add an AsyncStream that exposes notificationStatusUpdates on AirshipPushProtocol * removed status duplicate updates * added unit test * replaced asyncstream with airship channel * feedback --------- Co-authored-by: Igor Litvinenko --- Airship/AirshipCore/Source/Push.swift | 16 +++++++++++- Airship/AirshipCore/Source/PushProtocol.swift | 3 +++ .../AirshipCore/Tests/AirshipEventsTest.swift | 7 ++++++ Airship/AirshipCore/Tests/PushTest.swift | 25 +++++++++++++++++++ Airship/AirshipCore/Tests/TestPush.swift | 10 ++++++++ 5 files changed, 60 insertions(+), 1 deletion(-) diff --git a/Airship/AirshipCore/Source/Push.swift b/Airship/AirshipCore/Source/Push.swift index 12d2c3f1e..5e3b8bc29 100644 --- a/Airship/AirshipCore/Source/Push.swift +++ b/Airship/AirshipCore/Source/Push.swift @@ -35,6 +35,21 @@ final class AirshipPush: NSObject, AirshipPushProtocol, @unchecked Sendable { .removeDuplicates() .eraseToAnyPublisher() } + + var notificationStatusUpdates: AsyncStream { + let publisher = self.notificationStatusPublisher + return AsyncStream { continuation in + let cancellable = publisher + .removeDuplicates() + .sink { update in + continuation.yield(update) + } + + continuation.onTermination = { _ in + cancellable.cancel() + } + } + } private static let pushNotificationsOptionsKey = @@ -139,7 +154,6 @@ final class AirshipPush: NSObject, AirshipPushProtocol, @unchecked Sendable { super.init() - if config.requestAuthorizationToUseNotifications { let permissionDelegate = NotificationPermissionDelegate( registrar: self.notificationRegistrar diff --git a/Airship/AirshipCore/Source/PushProtocol.swift b/Airship/AirshipCore/Source/PushProtocol.swift index d12c4305f..30dc2303e 100644 --- a/Airship/AirshipCore/Source/PushProtocol.swift +++ b/Airship/AirshipCore/Source/PushProtocol.swift @@ -177,6 +177,9 @@ public protocol AirshipPushProtocol: AirshipBasePushProtocol { /// Notification status updates var notificationStatusPublisher: AnyPublisher { get } + + /// Notification status updates + var notificationStatusUpdates: AsyncStream { get async } /// Gets the current notification status var notificationStatus: AirshipNotificationStatus { get async } diff --git a/Airship/AirshipCore/Tests/AirshipEventsTest.swift b/Airship/AirshipCore/Tests/AirshipEventsTest.swift index 3557184f1..a26a9fcd2 100644 --- a/Airship/AirshipCore/Tests/AirshipEventsTest.swift +++ b/Airship/AirshipCore/Tests/AirshipEventsTest.swift @@ -374,6 +374,9 @@ private final class EventTestPush: AirshipPushProtocol, @unchecked Sendable { var notificationStatus: AirshipCore.AirshipNotificationStatus { fatalError("not implemented") } + + let notificationStatusUpdates: AsyncStream + let statusUpdateContinuation: AsyncStream.Continuation var isPushNotificationsOptedIn: Bool = false @@ -414,6 +417,10 @@ private final class EventTestPush: AirshipPushProtocol, @unchecked Sendable { ] var badgeNumber: Int = 0 + + init() { + (self.notificationStatusUpdates, self.statusUpdateContinuation) = AsyncStream.airshipMakeStreamWithContinuation() + } } private final class InternalPush: InternalPushProtocol { diff --git a/Airship/AirshipCore/Tests/PushTest.swift b/Airship/AirshipCore/Tests/PushTest.swift index 89d812a03..41bf1d8df 100644 --- a/Airship/AirshipCore/Tests/PushTest.swift +++ b/Airship/AirshipCore/Tests/PushTest.swift @@ -137,6 +137,31 @@ class PushTest: XCTestCase { await self.fulfillmentCompat(of: [completed], timeout: 10.0) XCTAssertFalse(self.push.userPromptedForNotifications) } + + func testNotificationsStatusPropogation() async throws { + XCTAssertFalse(self.push.userPromptedForNotifications) + + self.notificationRegistrar.onCheckStatus = { + return (.authorized, [.badge]) + } + + let completed = self.expectation(description: "Completed") + + let _ = await self.permissionsManager.requestPermission(.displayNotifications) + + let cancellable = self.push.notificationStatusPublisher.sink { status in + XCTAssertEqual(true, status.areNotificationsAllowed) + completed.fulfill() + } + + let status = await self.push.notificationStatus + XCTAssertEqual(true, status.areNotificationsAllowed) + + await self.fulfillmentCompat(of: [completed], timeout: 10.0) + XCTAssertTrue(self.push.userPromptedForNotifications) + + cancellable.cancel() + } /// Test that once prompted always prompted @MainActor diff --git a/Airship/AirshipCore/Tests/TestPush.swift b/Airship/AirshipCore/Tests/TestPush.swift index 181fa3e99..50f29abbe 100644 --- a/Airship/AirshipCore/Tests/TestPush.swift +++ b/Airship/AirshipCore/Tests/TestPush.swift @@ -7,6 +7,13 @@ import Foundation import Combine final class TestPush: NSObject, InternalPushProtocol, AirshipPushProtocol, AirshipComponent, @unchecked Sendable { + + override init() { + (self.notificationStatusUpdates, self.statusUpdateContinuation) = AsyncStream.airshipMakeStreamWithContinuation() + + super.init() + } + var quietTime: QuietTimeSettings? func enableUserPushNotifications() async -> Bool { @@ -32,6 +39,9 @@ final class TestPush: NSObject, InternalPushProtocol, AirshipPushProtocol, Airsh } let notificationStatusSubject: PassthroughSubject = PassthroughSubject() + + let notificationStatusUpdates: AsyncStream + let statusUpdateContinuation: AsyncStream.Continuation var notificationStatusPublisher: AnyPublisher { notificationStatusSubject.removeDuplicates().eraseToAnyPublisher() From c78874e57bff1a54604ee1165f9d27025fc4030f Mon Sep 17 00:00:00 2001 From: ilitvinenko Date: Sat, 29 Jun 2024 02:07:53 +0300 Subject: [PATCH 3/3] MOBILE-4529: Add a new subscription list updates call that works similarly to contact channels (#3144) * wip * feedback * added contact manuall refresh * fixes * added unit tests * Refresh on foreground * Remove actor * Wire up push refresh * Tests --------- Co-authored-by: Igor Litvinenko Co-authored-by: Ryan Lepinski --- Airship/Airship.xcodeproj/project.pbxproj | 24 ++ .../AirshipCore/Source/AirshipContact.swift | 73 ++--- .../BaseCachingRemoteDataProvider.swift | 256 +++++++++++++++++ .../Source/ChannelAudienceManager.swift | 73 +---- .../ChannelSubscriptionListProvider.swift | 132 +++++++++ .../Source/ContactChannelsProvider.swift | 262 ++++-------------- .../Source/SubscriptionListProvider.swift | 110 ++++++++ .../Tests/AirshipContactTest.swift | 62 ++++- .../Tests/ChannelAudienceManagerTest.swift | 6 +- .../Tests/ContactChannelsProviderTest.swift | 32 +++ ...TestContactSubscriptionListAPIClient.swift | 15 +- 11 files changed, 718 insertions(+), 327 deletions(-) create mode 100644 Airship/AirshipCore/Source/BaseCachingRemoteDataProvider.swift create mode 100644 Airship/AirshipCore/Source/ChannelSubscriptionListProvider.swift create mode 100644 Airship/AirshipCore/Source/SubscriptionListProvider.swift diff --git a/Airship/Airship.xcodeproj/project.pbxproj b/Airship/Airship.xcodeproj/project.pbxproj index 7537bdcfd..ed8d73b6c 100644 --- a/Airship/Airship.xcodeproj/project.pbxproj +++ b/Airship/Airship.xcodeproj/project.pbxproj @@ -185,6 +185,15 @@ 607951402A1E74AC0086578F /* MessageCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D3BCCF2A154D9400E07524 /* MessageCriteria.swift */; }; 607951412A1E74AC0086578F /* MessageCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D3BCCF2A154D9400E07524 /* MessageCriteria.swift */; }; 6087DB882B278F7600449BA8 /* JsonValueMatcherTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6087DB872B278F7600449BA8 /* JsonValueMatcherTest.swift */; }; + 608B16E62C2C1138005298FA /* SubscriptionListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16E52C2C1137005298FA /* SubscriptionListProvider.swift */; }; + 608B16E82C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16E72C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift */; }; + 608B16EB2C2C77B0005298FA /* SubscriptionListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16E52C2C1137005298FA /* SubscriptionListProvider.swift */; }; + 608B16EC2C2C77B1005298FA /* SubscriptionListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16E52C2C1137005298FA /* SubscriptionListProvider.swift */; }; + 608B16ED2C2C77B4005298FA /* ChannelSubscriptionListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16E72C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift */; }; + 608B16EE2C2C77B4005298FA /* ChannelSubscriptionListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16E72C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift */; }; + 608B16F12C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16F02C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift */; }; + 608B16F22C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16F02C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift */; }; + 608B16F32C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16F02C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift */; }; 60A5CC082B28DC500017EDB2 /* NotificationCategoriesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A5CC072B28DC500017EDB2 /* NotificationCategoriesTest.swift */; }; 60A5CC0C2B29AE890017EDB2 /* ProximityRegionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A5CC0B2B29AE890017EDB2 /* ProximityRegionTest.swift */; }; 60A5CC0E2B29B1B80017EDB2 /* CircularRegionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A5CC0D2B29B1B80017EDB2 /* CircularRegionTest.swift */; }; @@ -2049,6 +2058,9 @@ 6068E03A2B2CBCF200349E82 /* ActiveTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveTimer.swift; sourceTree = ""; }; 6079511F2A1CD19F0086578F /* ExperimentManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExperimentManagerTest.swift; sourceTree = ""; }; 6087DB872B278F7600449BA8 /* JsonValueMatcherTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonValueMatcherTest.swift; sourceTree = ""; }; + 608B16E52C2C1137005298FA /* SubscriptionListProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionListProvider.swift; sourceTree = ""; }; + 608B16E72C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelSubscriptionListProvider.swift; sourceTree = ""; }; + 608B16F02C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseCachingRemoteDataProvider.swift; sourceTree = ""; }; 60A5CC072B28DC500017EDB2 /* NotificationCategoriesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategoriesTest.swift; sourceTree = ""; }; 60A5CC0B2B29AE890017EDB2 /* ProximityRegionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityRegionTest.swift; sourceTree = ""; }; 60A5CC0D2B29B1B80017EDB2 /* CircularRegionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularRegionTest.swift; sourceTree = ""; }; @@ -3922,6 +3934,7 @@ 6ED735D926C73DC5003B0A7D /* Channel.swift */, 6ED735DC26C7401D003B0A7D /* TagEditor.swift */, E976486E27A46CC50024518D /* ChannelType.swift */, + 608B16E72C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift */, ); name = Channel; sourceTree = ""; @@ -3936,6 +3949,7 @@ 6E411D282538CF0500FEE4E8 /* Contacts */ = { isa = PBXGroup; children = ( + 608B16F02C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift */, E9343E1B27AC413700A49AAF /* AssociatedChannel.swift */, A67F87D1268DECCE00EF5F43 /* ContactAPIClient.swift */, 6EB11C862697ACBF00DC698F /* ContactOperation.swift */, @@ -3953,6 +3967,7 @@ 6E60EF6929DF542B003F7A8D /* AnonContactData.swift */, 6E1EEE8F2BD81AF300B45A87 /* ContactChannel.swift */, 990EB3B02BF59A1500315EAC /* ContactChannelsProvider.swift */, + 608B16E52C2C1137005298FA /* SubscriptionListProvider.swift */, ); name = Contacts; sourceTree = ""; @@ -6109,6 +6124,7 @@ 6E91E44F28EF423400B6F25E /* AirshipWorkerType.swift in Sources */, 6EB5158328A47C7100870C5A /* ScopedSubscriptionListEdit.swift in Sources */, 60D3BCD02A154D9400E07524 /* MessageCriteria.swift in Sources */, + 608B16E62C2C1138005298FA /* SubscriptionListProvider.swift in Sources */, 6E94761529BBC0240025F364 /* AirshipButton.swift in Sources */, 3251586B272AFB2E00DF8B44 /* MediaWebView.swift in Sources */, 6E96ED02294115210053CC91 /* AsyncStream.swift in Sources */, @@ -6162,6 +6178,7 @@ 6EFD6D6E27290C0B005B26F1 /* FormController.swift in Sources */, 6E91E44C28EF423400B6F25E /* AirshipWorkRequest.swift in Sources */, 6EE49BDD2A09AD3600AB1CF4 /* AirshipNotificationStatus.swift in Sources */, + 608B16F12C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift in Sources */, 6EFD6D82272A53AE005B26F1 /* PagerState.swift in Sources */, 6E9D529B26C1A77C004EA16B /* ActionRegistry.swift in Sources */, 6E87BE0726E283850005D20D /* DeepLinkDelegate.swift in Sources */, @@ -6221,6 +6238,7 @@ 6EF27DE62730E77300548DA3 /* RadioInputState.swift in Sources */, 6EB56C7C27AC4BBB00A7392F /* AssociatedChannel.swift in Sources */, 6E91E44328EF423400B6F25E /* WorkRateLimiterActor.swift in Sources */, + 608B16E82C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift in Sources */, 6ECB627E2A36A0770095C85C /* ExternalURLProcessor.swift in Sources */, 6E1BACDD2719FC0A0038399E /* ViewFactory.swift in Sources */, 6E9B4874288F0CE000C905B1 /* RateAppAction.swift in Sources */, @@ -6829,6 +6847,7 @@ A6722A80281A9EDA0033F54D /* ContactOperation.swift in Sources */, A6722A83281A9EDA0033F54D /* ContactConflictEvent.swift in Sources */, A6722A84281A9EDA0033F54D /* AirshipContact.swift in Sources */, + 608B16F32C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift in Sources */, A6722A86281A9EDA0033F54D /* EmailRegistrationOptions.swift in Sources */, 6E1589562AFF021D00954A04 /* SessionState.swift in Sources */, 6E91E43C28EF423400B6F25E /* WorkBackgroundTasks.swift in Sources */, @@ -6907,6 +6926,7 @@ A6722A48281A9EB80033F54D /* CircularRegion.swift in Sources */, A6722A4A281A9EB80033F54D /* EventAPIClient.swift in Sources */, A6722A4C281A9EB80033F54D /* EventUtils.swift in Sources */, + 608B16EE2C2C77B4005298FA /* ChannelSubscriptionListProvider.swift in Sources */, 6E7112AB2881D160004942E4 /* VisibilityViewModifier.swift in Sources */, 6E5A64D62AABBED600574085 /* MeteredUsageStore.swift in Sources */, A6722A4F281A9EB80033F54D /* MediaEventTemplate.swift in Sources */, @@ -7005,6 +7025,7 @@ 6E5A64D22AABBEAF00574085 /* AirshipMeteredUsageEvent.swift in Sources */, 6EE49C242A13E32B00AB1CF4 /* RemoteDataProviderProtocol.swift in Sources */, 6EC3670B2AD8A8A400355D11 /* AirshipEmbeddedObserver.swift in Sources */, + 608B16EC2C2C77B1005298FA /* SubscriptionListProvider.swift in Sources */, 6ECDDE7629B80462009D79DB /* ChannelAuthTokenProvider.swift in Sources */, 6E5A64DB2AABC5A400574085 /* UAMeteredUsage.xcdatamodeld in Sources */, 6EC367072AD8A8A400355D11 /* EmbeddedView.swift in Sources */, @@ -7309,6 +7330,7 @@ 6E94761629BBC0240025F364 /* AirshipButton.swift in Sources */, 6E664BDE26C4CD8700A2C8E5 /* OpenExternalURLAction.swift in Sources */, 6E15B71926CEB4190099C92D /* RemoteDataStore.swift in Sources */, + 608B16EB2C2C77B0005298FA /* SubscriptionListProvider.swift in Sources */, 6E96ED03294115210053CC91 /* AsyncStream.swift in Sources */, 6E739D6C26B9DFFB00BC6F6D /* AttributePendingMutations.swift in Sources */, 6E87BDBE26E01FF40005D20D /* ModuleLoader.swift in Sources */, @@ -7362,6 +7384,7 @@ 6E698DED26790AC300654DB2 /* PreferenceDataStore.swift in Sources */, E99605A227A071EA00365AE4 /* EmailRegistrationOptions.swift in Sources */, 6EFD6D6F27290C0B005B26F1 /* FormController.swift in Sources */, + 608B16F22C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift in Sources */, 6E87BE0826E283850005D20D /* DeepLinkDelegate.swift in Sources */, 6E91E44D28EF423400B6F25E /* AirshipWorkRequest.swift in Sources */, 6EE49BDE2A09AD3600AB1CF4 /* AirshipNotificationStatus.swift in Sources */, @@ -7421,6 +7444,7 @@ 6E96ECF7293FCE080053CC91 /* EventUploadScheduler.swift in Sources */, 6EF27DE72730E77300548DA3 /* RadioInputState.swift in Sources */, 6E71129E2880DACB004942E4 /* EventHandlerViewModifier.swift in Sources */, + 608B16ED2C2C77B4005298FA /* ChannelSubscriptionListProvider.swift in Sources */, A6F6726126E1162F008C69C3 /* JSONMatcher.swift in Sources */, 6E698DF126790AC300654DB2 /* AirshipLogger.swift in Sources */, 6EB56C7D27AC4BBC00A7392F /* AssociatedChannel.swift in Sources */, diff --git a/Airship/AirshipCore/Source/AirshipContact.swift b/Airship/AirshipCore/Source/AirshipContact.swift index fe1a73c6e..790541032 100644 --- a/Airship/AirshipCore/Source/AirshipContact.swift +++ b/Airship/AirshipCore/Source/AirshipContact.swift @@ -9,6 +9,8 @@ import Foundation /// within Airship. Contacts may be named and have channels associated with it. @objc(UAContact) public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked Sendable { + static let refreshContactPushPayloadKey = "com.urbanairship.contact.update" + public var contactChannelUpdates: AsyncStream { get { return self.contactChannelsProvider.contactChannels( @@ -68,8 +70,8 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked private let dataStore: PreferenceDataStore private let config: RuntimeConfig private let privacyManager: AirshipPrivacyManager - private let subscriptionListAPIClient: ContactSubscriptionListAPIClientProtocol private let contactChannelsProvider: ContactChannelsProviderProtocol + private let subscriptionListProvider: SubscriptionListProviderProtocol private let date: AirshipDateProtocol private let audienceOverridesProvider: AudienceOverridesProvider private let contactManager: ContactManagerProtocol @@ -77,7 +79,6 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked private let cachedSubscriptionLists: CachedValue<(String, [String: [ChannelScope]])> private var setupTask: Task? = nil private var subscriptions: Set = Set() - private let fetchSubscriptionListQueue: AirshipSerialQueue = AirshipSerialQueue() private let serialQueue: AirshipAsyncSerialQueue /// Publishes all edits made to the subscription lists through the SDK @@ -159,8 +160,8 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked config: RuntimeConfig, channel: InternalAirshipChannelProtocol, privacyManager: AirshipPrivacyManager, - subscriptionListAPIClient: ContactSubscriptionListAPIClientProtocol, contactChannelsProvider: ContactChannelsProviderProtocol, + subscriptionListProvider: SubscriptionListProviderProtocol, date: AirshipDateProtocol = AirshipDate.shared, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, audienceOverridesProvider: AudienceOverridesProvider, @@ -172,16 +173,15 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked self.dataStore = dataStore self.config = config self.privacyManager = privacyManager - self.subscriptionListAPIClient = subscriptionListAPIClient self.contactChannelsProvider = contactChannelsProvider self.audienceOverridesProvider = audienceOverridesProvider self.date = date self.contactManager = contactManager self.smsValidator = smsValidator self.serialQueue = serialQueue - + self.subscriptionListProvider = subscriptionListProvider self.cachedSubscriptionLists = CachedValue(date: date) - + super.init() self.setupTask = Task { @@ -330,12 +330,15 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked config: config, channel: channel, privacyManager: privacyManager, - subscriptionListAPIClient: ContactSubscriptionListAPIClient(config: config), contactChannelsProvider: ContactChannelsProvider( audienceOverrides: audienceOverridesProvider, apiClient: ContactChannelsAPIClient(config: config), privacyManager: privacyManager ), + subscriptionListProvider: SubscriptionListProvider( + audienceOverrides: audienceOverridesProvider, + apiClient: ContactSubscriptionListAPIClient(config: config), + privacyManager: privacyManager), audienceOverridesProvider: audienceOverridesProvider, contactManager: ContactManager( dataStore: dataStore, @@ -715,43 +718,7 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked public func fetchSubscriptionLists() async throws -> [String: [ChannelScope]] { let contactID = await getStableContactID() - var subscriptions = try await self.resolveSubscriptionLists(contactID) - - let overrides = await self.audienceOverridesProvider.contactOverrides(contactID: contactID) - - subscriptions = AudienceUtils.applySubscriptionListsUpdates( - subscriptions, - updates: overrides.subscriptionLists - ) - - return subscriptions - } - - private func resolveSubscriptionLists( - _ contactID: String - ) async throws -> [String: [ChannelScope]] { - - return try await self.fetchSubscriptionListQueue.run { - if let cached = self.cachedSubscriptionLists.value, - cached.0 == contactID { - return cached.1 - } - - let response = try await self.subscriptionListAPIClient.fetchSubscriptionLists( - contactID: contactID - ) - - guard response.isSuccess, let lists = response.result else { - throw AirshipErrors.error("Failed to fetch subscription lists") - } - - AirshipLogger.debug("Fetched lists finished with response: \(response)") - self.cachedSubscriptionLists.set( - value: (contactID, lists), - expiresIn: Self.maxSubscriptionListCacheAge - ) - return lists - } + return try await subscriptionListProvider.fetch(contactID: contactID) } @objc @@ -778,6 +745,8 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked self.lastResolveDate = self.date.now self.addOperation(.resolve) } + + self.contactChannelsProvider.refreshAsync() } @objc @@ -927,6 +896,22 @@ extension AirshipContact : InternalAirshipContactProtocol { } } +#if !os(watchOS) +extension AirshipContact: AirshipPushableComponent { + public func receivedRemoteNotification( + _ notification: [AnyHashable: Any], + completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + if notification[Self.refreshContactPushPayloadKey] == nil { + completionHandler(.noData) + } else { + self.contactChannelsProvider.refreshAsync() + completionHandler(.newData) + } + } +} +#endif + extension AirshipContact: AirshipComponent {} diff --git a/Airship/AirshipCore/Source/BaseCachingRemoteDataProvider.swift b/Airship/AirshipCore/Source/BaseCachingRemoteDataProvider.swift new file mode 100644 index 000000000..6f7885658 --- /dev/null +++ b/Airship/AirshipCore/Source/BaseCachingRemoteDataProvider.swift @@ -0,0 +1,256 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import Combine + +protocol CachingRemoteDataProviderResult: Sendable, Equatable { + var isSuccess: Bool { get } + + static func error(_ error: CachingRemoteDataError) -> any CachingRemoteDataProviderResult +} + +final actor BaseCachingRemoteDataProvider { + private let remoteFetcher: @Sendable (String) async throws -> AirshipHTTPResponse + private let cacheTtl: TimeInterval + private let overridesProvider: @Sendable (String) async -> AsyncStream + private let overridesApplier: @Sendable (Output, Overrides) async -> Output + private let isEnabled: @Sendable () -> Bool + private let taskSleeper: AirshipTaskSleeper + private var resolvers: [String: Resolver] = [:] + private let date: AirshipDateProtocol + + init( + remoteFetcher: @Sendable @escaping (String) async throws -> AirshipHTTPResponse, + overridesProvider: @Sendable @escaping (String) async -> AsyncStream, + overridesApplier: @Sendable @escaping (Output, Overrides) async -> Output, + isEnabled: @Sendable @escaping () -> Bool, + date: AirshipDateProtocol = AirshipDate.shared, + taskSleeper: AirshipTaskSleeper = .shared, + cacheTtl: TimeInterval = 600 + ) { + self.remoteFetcher = remoteFetcher + self.overridesProvider = overridesProvider + self.overridesApplier = overridesApplier + self.taskSleeper = taskSleeper + self.cacheTtl = cacheTtl + self.isEnabled = isEnabled + self.date = date + } + + private func getResolver(identifier: String, lastKnownIdentifier: String?) -> Resolver { + // The resolver for the lastKnownIdentifier can always be dropped, but we + // can't assume the identifier (channel or contact id) is the current stable contact ID or channel ID since + // its an async stream and we might not be on the last element. + + if let lastKnownIdentifier, lastKnownIdentifier != identifier { + resolvers[lastKnownIdentifier] = nil + } + + if let resolver = resolvers[identifier] { + return resolver + } + + let resolver = Resolver( + identifier: identifier, + overridesProvider: overridesProvider, + remoteFetcher: remoteFetcher, + cacheTtl: cacheTtl, + taskSleeper: taskSleeper, + overridesApplier: overridesApplier, + isEnabled: isEnabled, + date: self.date + ) + + resolvers[identifier] = resolver + + return resolver + } + + func refresh() async { + for resolver in resolvers.values { + await resolver.expireCache() + } + } + + /// Returns the latest channel result stream from the latest stable identifier + nonisolated func updates(identifierUpdates: AsyncStream) -> AsyncStream { + return AsyncStream { continuation in + let fetchTask = Task { [weak self] in + var resolverTask: Task? + var lastKnownIdentifier: String? = nil + for await identifier in identifierUpdates { + resolverTask?.cancel() + guard !Task.isCancelled else { return } + + guard + let resolver = await self?.getResolver( + identifier: identifier, + lastKnownIdentifier: lastKnownIdentifier + ) + else { + return + } + + resolverTask = Task { + for await update in await resolver.updates() { + guard !Task.isCancelled else { return } + continuation.yield(update) + } + } + + lastKnownIdentifier = identifier + } + } + + continuation.onTermination = { _ in + fetchTask.cancel() + } + } + } + + /// Manages the contact update API calls including backoff and override application + fileprivate actor Resolver { + private let identifier: String + private let overridesProvider: @Sendable (String) async -> AsyncStream + private let remoteFetcher: @Sendable (String) async throws -> AirshipHTTPResponse + private let cachedValue: CachedValue + private let fetchQueue: AirshipSerialQueue = AirshipSerialQueue() + private let cacheTtl: TimeInterval + private let taskSleeper: AirshipTaskSleeper + private let overridesApplier: (Output, Overrides) async -> Output + private let isEnabled: () -> Bool + + private let initialBackoff: TimeInterval = 8.0 + private let maxBackoff: TimeInterval = 64.0 + private var lastResults: [String: Output] = [:] + + private var waitTask: Task? = nil + + func expireCache() { + cachedValue.expire() + waitTask?.cancel() + } + + init( + identifier: String, + overridesProvider: @Sendable @escaping (String) async -> AsyncStream, + remoteFetcher: @Sendable @escaping (String) async throws -> AirshipHTTPResponse, + cacheTtl: TimeInterval, + taskSleeper: AirshipTaskSleeper, + overridesApplier: @escaping (Output, Overrides) async -> Output, + isEnabled: @escaping () -> Bool, + date: AirshipDateProtocol + ) { + self.identifier = identifier + self.overridesProvider = overridesProvider + self.remoteFetcher = remoteFetcher + self.cacheTtl = cacheTtl + self.taskSleeper = taskSleeper + self.overridesApplier = overridesApplier + self.isEnabled = isEnabled + self.cachedValue = CachedValue(date: date) + } + + func updates() -> AsyncStream { + let id = UUID().uuidString + + return AsyncStream { continuation in + let refreshTask = Task { + var backoff = self.initialBackoff + + repeat { + let fetched = await self.fetch() + let workingResult = if fetched.isSuccess { + fetched + } else if let lastResult = lastResults[id], lastResult.isSuccess { + lastResult + } else { + fetched + } + + guard !Task.isCancelled else { return } + + let overrideUpdates = await self.overridesProvider(identifier) + + let updateTask = Task { + for await overrides in overrideUpdates { + guard !Task.isCancelled else { + return + } + + let result = await overridesApplier(workingResult, overrides) + + if (lastResults[id] != result) { + continuation.yield(result) + lastResults[id] = result + } + } + } + + let timeToWait: TimeInterval + + if (fetched.isSuccess) { + timeToWait = cachedValue.timeRemaining + backoff = self.initialBackoff + } else { + timeToWait = backoff + + if backoff < self.maxBackoff { + backoff = backoff * 2 + } + } + + waitTask = Task { + try? await self.taskSleeper.sleep(timeInterval: timeToWait) + } + + await waitTask?.value + + updateTask.cancel() + } while (!Task.isCancelled) + } + + continuation.onTermination = { _ in + refreshTask.cancel() + } + } + } + + private func fetch() async -> Output { + guard isEnabled() else { + return Output.error(.disabled) as! Output + } + + return await self.fetchQueue.runSafe { [cachedValue, remoteFetcher, identifier, cacheTtl] in + + if let cached = cachedValue.value { + return cached + } + + do { + let response = try await remoteFetcher(identifier) + + guard response.isSuccess, let outputData = response.result else { + throw AirshipErrors.error("Failed to fetch associated channels list") + } + + cachedValue.set(value: outputData, expiresIn: cacheTtl) + + return outputData + + } catch { + AirshipLogger.warn( + "Received error when fetching contact channels \(error))" + ) + + return Output.error(.failedToFetch) as! Output + } + } + } + } +} + +enum CachingRemoteDataError: Error, Equatable, Sendable, Hashable { + case disabled + case failedToFetch +} diff --git a/Airship/AirshipCore/Source/ChannelAudienceManager.swift b/Airship/AirshipCore/Source/ChannelAudienceManager.swift index c0251c59e..c10226271 100644 --- a/Airship/AirshipCore/Source/ChannelAudienceManager.swift +++ b/Airship/AirshipCore/Source/ChannelAudienceManager.swift @@ -40,7 +40,7 @@ final class ChannelAudienceManager: ChannelAudienceManagerProtocol { private let dataStore: PreferenceDataStore private let privacyManager: AirshipPrivacyManager private let workManager: AirshipWorkManagerProtocol - private let subscriptionListClient: SubscriptionListAPIClientProtocol + private let subscriptionListProvider: ChannelSubscriptionListProviderProtocol private let updateClient: ChannelBulkUpdateAPIClientProtocol private let audienceOverridesProvider: AudienceOverridesProvider @@ -89,7 +89,7 @@ final class ChannelAudienceManager: ChannelAudienceManagerProtocol { init( dataStore: PreferenceDataStore, workManager: AirshipWorkManagerProtocol, - subscriptionListClient: SubscriptionListAPIClientProtocol, + subscriptionListProvider: ChannelSubscriptionListProviderProtocol, updateClient: ChannelBulkUpdateAPIClientProtocol, privacyManager: AirshipPrivacyManager, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, @@ -99,7 +99,7 @@ final class ChannelAudienceManager: ChannelAudienceManagerProtocol { self.dataStore = dataStore self.workManager = workManager self.privacyManager = privacyManager - self.subscriptionListClient = subscriptionListClient + self.subscriptionListProvider = subscriptionListProvider self.updateClient = updateClient self.date = date self.cachedSubscriptionLists = CachedValue(date: date) @@ -151,7 +151,9 @@ final class ChannelAudienceManager: ChannelAudienceManagerProtocol { self.init( dataStore: dataStore, workManager: AirshipWorkManager.shared, - subscriptionListClient: SubscriptionListAPIClient(config: config), + subscriptionListProvider: ChannelSubscriptionListProvider( + audienceOverrides: audienceOverridesProvider, + apiClient: SubscriptionListAPIClient(config: config)), updateClient: ChannelBulkUpdateAPIClient(config: config), privacyManager: privacyManager, audienceOverridesProvider: audienceOverridesProvider @@ -237,20 +239,7 @@ final class ChannelAudienceManager: ChannelAudienceManagerProtocol { throw AirshipErrors.error("Channel not created yet") } - var listIDs = try await self.resolveSubscriptionLists( - channelID: channelID - ) - - let overrides = await self.audienceOverridesProvider.channelOverrides( - channelID: channelID - ) - - listIDs = self.applySubscriptionListUpdates( - listIDs, - updates: overrides.subscriptionLists - ) - - return listIDs + return try await subscriptionListProvider.fetch(channelID: channelID) } func pendingOverrides(channelID: String) -> ChannelAudienceOverrides { @@ -277,54 +266,6 @@ final class ChannelAudienceManager: ChannelAudienceManagerProtocol { ) } - private func resolveSubscriptionLists( - channelID: String - ) async throws -> [String] { - if let cached = self.cachedSubscriptionLists.value { - return cached - } - - let response = try await self.subscriptionListClient.get( - channelID: channelID - ) - - guard response.isSuccess, let lists = response.result else { - throw AirshipErrors.error( - "Failed to fetch subscription lists with status: \(response.statusCode)" - ) - } - - self.cachedSubscriptionLists.set( - value: lists, - expiresIn: ChannelAudienceManager.maxCacheTime - ) - - - return lists - } - - private func applySubscriptionListUpdates( - _ ids: [String], - updates: [SubscriptionListUpdate] - ) -> [String] { - guard !updates.isEmpty else { - return ids - } - - var result = ids - updates.forEach { update in - switch update.type { - case .subscribe: - if !result.contains(update.listId) { - result.append(update.listId) - } - case .unsubscribe: - result.removeAll(where: { $0 == update.listId }) - } - } - - return result - } @objc private func checkPrivacyManager() { if !self.privacyManager.isEnabled(.tagsAndAttributes) { diff --git a/Airship/AirshipCore/Source/ChannelSubscriptionListProvider.swift b/Airship/AirshipCore/Source/ChannelSubscriptionListProvider.swift new file mode 100644 index 000000000..f79cdb9b2 --- /dev/null +++ b/Airship/AirshipCore/Source/ChannelSubscriptionListProvider.swift @@ -0,0 +1,132 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import Combine + +/** + * Subscription list provider protocol for receiving contact updates. + * @note For internal use only. :nodoc: + */ +protocol ChannelSubscriptionListProviderProtocol: Sendable { + func fetch(channelID: String) async throws -> [String] +} + +final class ChannelSubscriptionListProvider: ChannelSubscriptionListProviderProtocol { + + private let actor: BaseCachingRemoteDataProvider + private let overridesApplier = OverridesApplier() + + init( + audienceOverrides: AudienceOverridesProvider, + apiClient: SubscriptionListAPIClientProtocol, + date: AirshipDateProtocol = AirshipDate.shared, + taskSleeper: AirshipTaskSleeper = .shared, + maxChannelListCacheAgeSeconds: TimeInterval = 600 + ) { + + self.actor = BaseCachingRemoteDataProvider( + remoteFetcher: { channelID in + return try await apiClient + .get(channelID: channelID) + .map(onMap: { response in + guard let result = response.result else { + return nil + } + + return .success(result) + }) + }, + overridesProvider: { channelID in + return AsyncStream { continuation in + Task { + let override = await audienceOverrides.channelOverrides(channelID: channelID) + continuation.yield(override) + continuation.finish() + } + } + }, + overridesApplier: { [overridesApplier] result, overrides in + guard + case .success(let list) = result + else { + return result + } + + return .success(overridesApplier.applySubscriptionListUpdates(list, updates: overrides.subscriptionLists)) + }, + isEnabled: { true }, + date: date, + taskSleeper: taskSleeper, + cacheTtl: maxChannelListCacheAgeSeconds + ) + } + + + func fetch(channelID: String) async throws -> [String] { + var stream = actor.updates(identifierUpdates: AsyncStream { continuation in + continuation.yield(channelID) + continuation.finish() + }) + .makeAsyncIterator() + + guard let result = await stream.next() else { + throw AirshipErrors.error("Failed to get subscription list") + } + + switch result { + case .fail(let error): throw error + case .success(let list): return list + } + } +} + +enum ChannelSubscriptionListResult: Equatable, Sendable, Hashable, CachingRemoteDataProviderResult { + static func error(_ error: CachingRemoteDataError) -> any CachingRemoteDataProviderResult { + return ChannelSubscriptionListResult.fail(error) + } + + case success([String]) + case fail(CachingRemoteDataError) + + public var subscriptionList: [String] { + get throws { + switch(self) { + case .fail(let error): throw error + case .success(let list): return list + } + } + } + + public var isSuccess: Bool { + switch(self) { + case .fail(_): return false + case .success(_): return true + } + } +} + +private struct OverridesApplier { + + func applySubscriptionListUpdates( + _ ids: [String], + updates: [SubscriptionListUpdate] + ) -> [String] { + guard !updates.isEmpty else { + return ids + } + + var result = ids + updates.forEach { update in + switch update.type { + case .subscribe: + if !result.contains(update.listId) { + result.append(update.listId) + } + case .unsubscribe: + result.removeAll(where: { $0 == update.listId }) + } + } + + return result + } +} diff --git a/Airship/AirshipCore/Source/ContactChannelsProvider.swift b/Airship/AirshipCore/Source/ContactChannelsProvider.swift index 9a2c63efe..ac776b0fd 100644 --- a/Airship/AirshipCore/Source/ContactChannelsProvider.swift +++ b/Airship/AirshipCore/Source/ContactChannelsProvider.swift @@ -7,21 +7,16 @@ import Combine * Contact channels provider protocol for receiving contact updates. * @note For internal use only. :nodoc: */ -protocol ContactChannelsProviderProtocol: AnyActor { +protocol ContactChannelsProviderProtocol: Sendable { func contactChannels(stableContactIDUpdates: AsyncStream) -> AsyncStream + func refresh() async + func refreshAsync() } -/// Provides a stream of contact updates at a regular interval -final actor ContactChannelsProvider: ContactChannelsProviderProtocol { - private let audienceOverrides: AudienceOverridesProvider - private let apiClient: ContactChannelsAPIClientProtocol - private let maxChannelListCacheAge: TimeInterval +final class ContactChannelsProvider: ContactChannelsProviderProtocol { + private let actor: BaseCachingRemoteDataProvider private let overridesApplier: OverridesApplier = OverridesApplier() - private let taskSleeper: AirshipTaskSleeper - private let privacyManager: AirshipPrivacyManager - private var resolvers: [String: Resolver] = [:] - private let date: AirshipDateProtocol - + init( audienceOverrides: AudienceOverridesProvider, apiClient: ContactChannelsAPIClientProtocol, @@ -30,212 +25,42 @@ final actor ContactChannelsProvider: ContactChannelsProviderProtocol { maxChannelListCacheAgeSeconds: TimeInterval = 600, privacyManager: AirshipPrivacyManager ) { - self.audienceOverrides = audienceOverrides - self.apiClient = apiClient - self.taskSleeper = taskSleeper - self.maxChannelListCacheAge = maxChannelListCacheAgeSeconds - self.privacyManager = privacyManager - self.date = date - } - - private func getResolver(contactID: String, lastContactID: String?) -> Resolver { - // The resolver for the lastContactID can always be dropped, but we - // can't assume the contactID is the current stable contact ID since - // its an async stream and we might not be on the last element. - - if let lastContactID { - resolvers[lastContactID] = nil - } - - if let resolver = resolvers[contactID] { - return resolver - } - - let resolver = Resolver( - contactID: contactID, - audienceOverrides: audienceOverrides, - apiClient: apiClient, - maxChannelListCacheAge: maxChannelListCacheAge, + self.actor = BaseCachingRemoteDataProvider( + remoteFetcher: { contactID in + return try await apiClient + .fetchAssociatedChannelsList(contactID: contactID) + .map { response in + guard let result = response.result else { + return nil + } + return .success(result) + } + }, + overridesProvider: { identifier in + return await audienceOverrides.contactOverrideUpdates(contactID: identifier) + }, + overridesApplier: { [overridesApplier] result, overrides in + return await overridesApplier.applyUpdates(result: result, overrides: overrides) + }, + isEnabled: { privacyManager.isEnabled(.contacts) }, + date: date, taskSleeper: taskSleeper, - overridesApplier: overridesApplier, - privacyManager: privacyManager, - date: self.date + cacheTtl: maxChannelListCacheAgeSeconds ) - - resolvers[contactID] = resolver - - return resolver } - + /// Returns the latest contact channel result stream from the latest stable contact ID - nonisolated func contactChannels(stableContactIDUpdates: AsyncStream) -> AsyncStream { - return AsyncStream { continuation in - let fetchTask = Task { [weak self] in - var resolverTask: Task? - var lastContactID: String? = nil - for await contactID in stableContactIDUpdates { - resolverTask?.cancel() - guard !Task.isCancelled else { return } - - guard - let resolver = await self?.getResolver( - contactID: contactID, - lastContactID: lastContactID - ) - else { - return - } - - resolverTask = Task { - for await update in await resolver.contactUpdates() { - guard !Task.isCancelled else { return } - continuation.yield(update) - } - } - - lastContactID = contactID - } - } - - continuation.onTermination = { _ in - fetchTask.cancel() - } - } + func contactChannels(stableContactIDUpdates: AsyncStream) -> AsyncStream { + return actor.updates(identifierUpdates: stableContactIDUpdates) + } + + func refresh() async { + await actor.refresh() } - /// Manages the contact update API calls including backoff and override application - fileprivate actor Resolver { - private let contactID: String - private let audienceOverrides: AudienceOverridesProvider - private let apiClient: ContactChannelsAPIClientProtocol - private let cachedChannelsList: CachedValue<[ContactChannel]> - private let fetchQueue: AirshipSerialQueue = AirshipSerialQueue() - private let maxChannelListCacheAge: TimeInterval - private let taskSleeper: AirshipTaskSleeper - private let overridesApplier: OverridesApplier - private let privacyManager: AirshipPrivacyManager - - private static let initialBackoff: TimeInterval = 8.0 - private static let maxBackoff: TimeInterval = 64.0 - private var lastResults: [String: ContactChannelsResult] = [:] - - init( - contactID: String, - audienceOverrides: AudienceOverridesProvider, - apiClient: ContactChannelsAPIClientProtocol, - maxChannelListCacheAge: TimeInterval, - taskSleeper: AirshipTaskSleeper, - overridesApplier: OverridesApplier, - privacyManager: AirshipPrivacyManager, - date: AirshipDateProtocol - ) { - self.contactID = contactID - self.audienceOverrides = audienceOverrides - self.apiClient = apiClient - self.maxChannelListCacheAge = maxChannelListCacheAge - self.taskSleeper = taskSleeper - self.overridesApplier = overridesApplier - self.privacyManager = privacyManager - self.cachedChannelsList = CachedValue(date: date) - } - - func contactUpdates() -> AsyncStream { - let id = UUID().uuidString - - return AsyncStream { continuation in - let refreshTask = Task { - var backoff = Self.initialBackoff - - repeat { - let fetched = await self.fetch() - let workingResult: ContactChannelsResult = if fetched.isSuccess { - fetched - } else if let lastResult = lastResults[id], lastResult.isSuccess { - lastResult - } else { - fetched - } - - guard !Task.isCancelled else { return } - - let overrideUpdates = await self.audienceOverrides.contactOverrideUpdates( - contactID: contactID - ) - - let updateTask = Task { - for await overrides in overrideUpdates { - guard !Task.isCancelled else { - return - } - - let result = await overridesApplier.applyUpdates( - result: workingResult, - overrides: overrides - ) - - if (lastResults[id] != result) { - continuation.yield(result) - lastResults[id] = result - } - } - } - - if (fetched.isSuccess) { - try await self.taskSleeper.sleep( - timeInterval: cachedChannelsList.timeRemaining - ) - backoff = Self.initialBackoff - } else { - try await self.taskSleeper.sleep( - timeInterval: backoff - ) - if backoff < Self.maxBackoff { - backoff = backoff * 2 - } - } - - updateTask.cancel() - } while (!Task.isCancelled) - } - - continuation.onTermination = { _ in - refreshTask.cancel() - } - } - } - - private func fetch() async -> ContactChannelsResult { - guard privacyManager.isEnabled(.contacts) else { - return .error(.contactsDisabled) - } - - return await self.fetchQueue.runSafe { [cachedChannelsList, apiClient, contactID, maxChannelListCacheAge] in - if let cached = cachedChannelsList.value { - return .success(cached) - } - - do { - let response = try await apiClient.fetchAssociatedChannelsList( - contactID: contactID - ) - - guard response.isSuccess, let list = response.result else { - throw AirshipErrors.error("Failed to fetch associated channels list") - } - - cachedChannelsList.set( - value: list, - expiresIn: maxChannelListCacheAge - ) - return .success(list) - } catch { - AirshipLogger.warn( - "Received error when fetching contact channels \(error))" - ) - - return .error(.failedToFetchContacts) - } - } + func refreshAsync() { + Task { + await refresh() } } } @@ -245,7 +70,20 @@ public enum ContactChannelErrors: Error, Equatable, Sendable, Hashable { case failedToFetchContacts } -public enum ContactChannelsResult: Equatable, Sendable, Hashable { +fileprivate extension CachingRemoteDataError { + func toChannelError() -> ContactChannelErrors { + switch (self) { + case .disabled: return .contactsDisabled + case .failedToFetch: return .failedToFetchContacts + } + } +} + +public enum ContactChannelsResult: Equatable, Sendable, Hashable, CachingRemoteDataProviderResult { + static func error(_ error: CachingRemoteDataError) -> any CachingRemoteDataProviderResult { + return ContactChannelsResult.error(error.toChannelError()) + } + case success([ContactChannel]) case error(ContactChannelErrors) diff --git a/Airship/AirshipCore/Source/SubscriptionListProvider.swift b/Airship/AirshipCore/Source/SubscriptionListProvider.swift new file mode 100644 index 000000000..7f687d14d --- /dev/null +++ b/Airship/AirshipCore/Source/SubscriptionListProvider.swift @@ -0,0 +1,110 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import Combine + +/** + * Subscription list provider protocol for receiving contact updates. + * @note For internal use only. :nodoc: + */ +protocol SubscriptionListProviderProtocol: Sendable { + func subscriptionList(stableContactIDUpdates: AsyncStream) -> AsyncStream + func fetch(contactID: String) async throws -> [String: [ChannelScope]] + func refresh() async +} + +final class SubscriptionListProvider: SubscriptionListProviderProtocol { + + private let actor: BaseCachingRemoteDataProvider + + init( + audienceOverrides: AudienceOverridesProvider, + apiClient: ContactSubscriptionListAPIClientProtocol, + date: AirshipDateProtocol = AirshipDate.shared, + taskSleeper: AirshipTaskSleeper = .shared, + maxChannelListCacheAgeSeconds: TimeInterval = 600, + privacyManager: AirshipPrivacyManager + ) { + + self.actor = BaseCachingRemoteDataProvider( + remoteFetcher: { contactID in + return try await apiClient + .fetchSubscriptionLists(contactID: contactID) + .map(onMap: { response in + guard let result = response.result else { + return nil + } + + return .success(result) + }) + }, + overridesProvider: { identifier in + return await audienceOverrides.contactOverrideUpdates(contactID: identifier) + }, + overridesApplier: { result, overrides in + guard + case .success(let list) = result + else { + return result + } + + let updated = AudienceUtils.applySubscriptionListsUpdates(list, updates: overrides.subscriptionLists) + return .success(updated) + }, + isEnabled: { privacyManager.isEnabled(.contacts) }, + date: date, + taskSleeper: taskSleeper, + cacheTtl: maxChannelListCacheAgeSeconds + ) + } + + func subscriptionList(stableContactIDUpdates: AsyncStream) -> AsyncStream { + return actor.updates(identifierUpdates: stableContactIDUpdates) + } + + func refresh() async { + await actor.refresh() + } + + func fetch(contactID: String) async throws -> [String: [ChannelScope]] { + var stream = actor.updates(identifierUpdates: AsyncStream { continuation in + continuation.yield(contactID) + continuation.finish() + }) + .makeAsyncIterator() + + guard let result = await stream.next() else { + throw AirshipErrors.error("Failed to get subscription list") + } + + switch result { + case .fail(let error): throw error + case .success(let list): return list + } + } +} + +enum SubscriptionListResult: Equatable, Sendable, Hashable, CachingRemoteDataProviderResult { + static func error(_ error: CachingRemoteDataError) -> any CachingRemoteDataProviderResult { + return SubscriptionListResult.fail(error) + } + + case success([String: [ChannelScope]]) + case fail(CachingRemoteDataError) + + public var subscriptionList: [String: [ChannelScope]] { + get throws { + switch(self) { + case .fail(let error): throw error + case .success(let list): return list + } + } + } + + public var isSuccess: Bool { + switch(self) { + case .fail(_): return false + case .success(_): return true + } + } +} diff --git a/Airship/AirshipCore/Tests/AirshipContactTest.swift b/Airship/AirshipCore/Tests/AirshipContactTest.swift index a68558cee..f8294ec4c 100644 --- a/Airship/AirshipCore/Tests/AirshipContactTest.swift +++ b/Airship/AirshipCore/Tests/AirshipContactTest.swift @@ -21,6 +21,7 @@ class AirshipContactTest: XCTestCase { private var contact: AirshipContact! private var privacyManager: AirshipPrivacyManager! private var config: RuntimeConfig! + private var subscriptionProvider: SubscriptionListProviderProtocol! override func setUp() async throws { self.config = RuntimeConfig(config: AirshipConfig(), dataStore: dataStore) @@ -30,6 +31,12 @@ class AirshipContactTest: XCTestCase { defaultEnabledFeatures: .all, notificationCenter: self.notificationCenter ) + + self.subscriptionProvider = SubscriptionListProvider( + audienceOverrides: self.audienceOverridesProvider, + apiClient: self.apiClient, + date: self.date, + privacyManager: self.privacyManager) self.channel.identifier = "channel id" setupContact() @@ -45,8 +52,8 @@ class AirshipContactTest: XCTestCase { config: config, channel: self.channel, privacyManager: self.privacyManager, - subscriptionListAPIClient: self.apiClient, - contactChannelsProvider: self.contactChannelsProvider, + contactChannelsProvider: self.contactChannelsProvider, + subscriptionListProvider: subscriptionProvider, date: self.date, notificationCenter: self.notificationCenter, audienceOverridesProvider: self.audienceOverridesProvider, @@ -268,6 +275,23 @@ class AirshipContactTest: XCTestCase { await verifyOperations([.resolve]) } + func testRefreshContactChannelsOnActive() async throws { + notificationCenter.post( + name: AppStateTracker.didBecomeActiveNotification + ) + + XCTAssertTrue(contactChannelsProvider.refreshedCalled) + } + + func testRefreshContactChannelsOnPush() async throws { + self.contact.receivedRemoteNotification( + [ + "com.urbanairship.contact.update": NSNumber(value: true) + ] + ) { _ in } + + XCTAssertTrue(contactChannelsProvider.refreshedCalled) + } func testForegroundSkipsResolves() async throws { notificationCenter.post( @@ -500,6 +524,40 @@ class AirshipContactTest: XCTestCase { lists = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) } + + func testFetchSubscriptionListsReset() async throws { + await self.contactManager.setCurrentContactIDInfo( + ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) + ) + + var apiResult: [String: [ChannelScope]] = ["neat": [.web]] + var expected = apiResult + self.apiClient.fetchSubscriptionListsCallback = { + identifier in + XCTAssertEqual("some-contact-id", identifier) + return AirshipHTTPResponse( + result: apiResult, + statusCode: 200, + headers: [:] + ) + } + + // Populate cache + var lists: [String: [ChannelScope]] = try await self.contact.fetchSubscriptionLists() + + XCTAssertEqual(expected, lists) + + apiResult = ["something else": [.web]] + + lists = try await self.contact.fetchSubscriptionLists() + XCTAssertEqual(expected, lists) + + await subscriptionProvider.refresh() + + lists = try await self.contact.fetchSubscriptionLists() + expected = apiResult + XCTAssertEqual(expected, lists) + } func testFetchSubscriptionListsCachedDifferentContactID() async throws { await self.contactManager.setCurrentContactIDInfo( diff --git a/Airship/AirshipCore/Tests/ChannelAudienceManagerTest.swift b/Airship/AirshipCore/Tests/ChannelAudienceManagerTest.swift index a48bab93d..2dfd22b6a 100644 --- a/Airship/AirshipCore/Tests/ChannelAudienceManagerTest.swift +++ b/Airship/AirshipCore/Tests/ChannelAudienceManagerTest.swift @@ -33,7 +33,11 @@ class ChannelAudienceManagerTest: XCTestCase { self.audienceManager = ChannelAudienceManager( dataStore: self.dataStore, workManager: self.workManager, - subscriptionListClient: self.subscriptionListClient, + subscriptionListProvider: ChannelSubscriptionListProvider( + audienceOverrides: self.audienceOverridesProvider, + apiClient: self.subscriptionListClient, + date: self.date + ), updateClient: self.updateClient, privacyManager: self.privacyManager, notificationCenter: self.notificationCenter, diff --git a/Airship/AirshipCore/Tests/ContactChannelsProviderTest.swift b/Airship/AirshipCore/Tests/ContactChannelsProviderTest.swift index 45cb87258..14a88f4e6 100644 --- a/Airship/AirshipCore/Tests/ContactChannelsProviderTest.swift +++ b/Airship/AirshipCore/Tests/ContactChannelsProviderTest.swift @@ -153,6 +153,38 @@ class ContactChannelsProviderTest: XCTestCase { XCTAssertEqual(self.apiClient.fetchAssociatedChannelsCallCount, 3) } + + func testContactChannelsRefresh() async { + let contactIDChannel = AirshipAsyncChannel() + + var resultStream = provider.contactChannels( + stableContactIDUpdates: await contactIDChannel.makeStream() + ).makeAsyncIterator() + + + self.apiClient.fetchResponse = AirshipHTTPResponse( + result: self.testChannels1, + statusCode: 200, + headers: [:] + ) + await contactIDChannel.send("test-contact-id-1") + var result = await resultStream.next() + XCTAssertEqual(result, .success(self.testChannels1)) + XCTAssertEqual(1, self.apiClient.fetchAssociatedChannelsCallCount) + + //from cache + await contactIDChannel.send("test-contact-id-1") + result = await resultStream.next() + XCTAssertEqual(result, .success(self.testChannels1)) + XCTAssertEqual(1, self.apiClient.fetchAssociatedChannelsCallCount) + + await provider.refresh() + + await contactIDChannel.send("test-contact-id-1") + result = await resultStream.next() + XCTAssertEqual(result, .success(self.testChannels1)) + XCTAssertEqual(2, self.apiClient.fetchAssociatedChannelsCallCount) + } func testContactChannelsFailure() async { let contactIDStream = AsyncStream { continuation in diff --git a/Airship/AirshipCore/Tests/TestContactSubscriptionListAPIClient.swift b/Airship/AirshipCore/Tests/TestContactSubscriptionListAPIClient.swift index a921af5d1..a87a46fca 100644 --- a/Airship/AirshipCore/Tests/TestContactSubscriptionListAPIClient.swift +++ b/Airship/AirshipCore/Tests/TestContactSubscriptionListAPIClient.swift @@ -16,14 +16,25 @@ class TestContactSubscriptionListAPIClient: ContactSubscriptionListAPIClientProt } -actor TestContactChannelsProvider: ContactChannelsProviderProtocol, @unchecked Sendable { - nonisolated func contactChannels(stableContactIDUpdates: AsyncStream) -> AsyncStream { +final class TestContactChannelsProvider: ContactChannelsProviderProtocol, @unchecked Sendable { + func contactChannels(stableContactIDUpdates: AsyncStream) -> AsyncStream { return AsyncStream { _ in } } func contactUpdates(contactID: String) async throws -> AsyncStream<[ContactChannel]> { return AsyncStream<[ContactChannel]> { _ in } } + + func refresh() async { + refreshedCalled = true + } + + var refreshedCalled = false + + + func refreshAsync() { + refreshedCalled = true + } init() {} }