diff --git a/DemoSwiftApp/AppDelegate.swift b/DemoSwiftApp/AppDelegate.swift index a2304653d..c82e7e2bb 100644 --- a/DemoSwiftApp/AppDelegate.swift +++ b/DemoSwiftApp/AppDelegate.swift @@ -120,24 +120,38 @@ class AppDelegate: UIResponder, UIApplicationDelegate { addNotificationListeners() - // initialize SDK - optimizely!.start { result in - switch result { - case .failure(let error): - print("Optimizely SDK initiliazation failed: \(error)") - case .success: - print("Optimizely SDK initialized successfully!") - @unknown default: - print("Optimizely SDK initiliazation failed with unknown result") + if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) { + Task { + + do { + try await optimizely.start() + print("Optimizely SDK initialized successfully!") + self.startWithRootViewController() + } catch { + print("Optimizely SDK initiliazation failed: \(error)") + } + + } + } else { + optimizely.start { result in + switch result { + case .failure(let error): + print("Optimizely SDK initiliazation failed: \(error)") + case .success: + print("Optimizely SDK initialized successfully!") + @unknown default: + print("Optimizely SDK initiliazation failed with unknown result") + } + + self.startWithRootViewController() } - - self.startWithRootViewController() - - // For sample codes for APIs, see "Samples/SamplesForAPI.swift" - //SamplesForAPI.checkOptimizelyConfig(optimizely: self.optimizely) - //SamplesForAPI.checkOptimizelyUserContext(optimizely: self.optimizely) - //SamplesForAPI.checkAudienceSegments(optimizely: self.optimizely) } + + // For sample codes for APIs, see "Samples/SamplesForAPI.swift" + //SamplesForAPI.checkOptimizelyConfig(optimizely: self.optimizely) + //SamplesForAPI.checkOptimizelyUserContext(optimizely: self.optimizely) + //SamplesForAPI.chgiteckAudienceSegments(optimizely: self.optimizely) + } func addNotificationListeners() { diff --git a/DemoSwiftApp/Samples/SamplesForAPI.swift b/DemoSwiftApp/Samples/SamplesForAPI.swift index 61794d51e..7ec817e66 100644 --- a/DemoSwiftApp/Samples/SamplesForAPI.swift +++ b/DemoSwiftApp/Samples/SamplesForAPI.swift @@ -302,14 +302,15 @@ class SamplesForAPI { defaultLogLevel: .debug) guard let localDatafileUrl = Bundle.main.url(forResource: "demoTestDatafile", withExtension: "json"), - let localDatafile = try? Data(contentsOf: localDatafileUrl) + let localDatafile = try? Data(contentsOf: localDatafileUrl) else { fatalError("Local datafile cannot be found") } - + try? optimizely.start(datafile: localDatafile) - + let user = optimizely.createUserContext(userId: "user_123", attributes: ["location": "NY"]) + user.fetchQualifiedSegments(options: [.ignoreCache]) { error in guard error == nil else { print("[AudienceSegments] \(error!)") @@ -319,6 +320,27 @@ class SamplesForAPI { let decision = user.decide(key: "show_coupon", options: [.includeReasons]) print("[AudienceSegments] decision: \(decision)") } + + if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) { + Task { [user] in + do { + try await user.fetchQualifiedSegments(options: [.ignoreCache]) + let decision = user.decide(key: "show_coupon", options: [.includeReasons]) + print("[AudienceSegments] decision: \(decision)") + } catch { + print("[AudienceSegments] \(error)") + } + + // Without segment option + do { + try await user.fetchQualifiedSegments() + let decision = user.decide(key: "show_coupon") + print("[AudienceSegments] decision: \(decision)") + } catch { + print("[AudienceSegments] \(error)") + } + } + } } // MARK: - Initializations @@ -386,6 +408,23 @@ class SamplesForAPI { } print("activated variation: \(String(describing: variationKey))") + + // [A3] Asynchronous initialization (aync-await) + // 1. A datafile is downloaded from the server and the SDK is initialized with the datafile + // 2. Polling datafile periodically. + // The cached datafile is used immediately to update the SDK project config. + optimizely = OptimizelyClient(sdkKey: "", + periodicDownloadInterval: 60) + if #available(iOS 13, *) { + Task { [optimizely] in + do { + try await optimizely.start() + } catch { + print("Optimizely SDK initiliazation failed: \(error)") + } + } + } + } } diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 7b0f0edd7..6595fc6cc 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -1978,6 +1978,8 @@ 84F6BAB427FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F6BAB227FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift */; }; 84F6BADD27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */; }; 84F6BADE27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */; }; + 98137C552A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */; }; + 98137C572A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2414,6 +2416,8 @@ 84E7ABBA27D2A1F100447CAE /* ThreadSafeLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThreadSafeLogger.swift; sourceTree = ""; }; 84F6BAB227FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP.swift; sourceTree = ""; }; 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Decide.swift; sourceTree = ""; }; + 98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_Init_Async_Await.swift; sourceTree = ""; }; + 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Aync_Await.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -2946,6 +2950,7 @@ 6E7E9B362523F8BF009E4426 /* OptimizelyUserContextTests_Decide_Reasons.swift */, 6EB97BCC24C89DFB00068883 /* OptimizelyUserContextTests_Decide_Legacy.swift */, 6E0A72D326C5B9AE00FF92B5 /* OptimizelyUserContextTests_ForcedDecisions.swift */, + 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */, 84F6BAB227FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift */, 84644AB228F0D2B3003FB9CB /* OptimizelyUserContextTests_ODP_2.swift */, 84F6BADC27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift */, @@ -3019,6 +3024,7 @@ children = ( 6E7519BC22C5211100B2B157 /* OptimizelyErrorTests.swift */, 6E5AB69223F6130D007A82B1 /* OptimizelyClientTests_Init_Async.swift */, + 98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */, 6E5AB69123F6130C007A82B1 /* OptimizelyClientTests_Init_Sync.swift */, 6E7519C222C5211100B2B157 /* OptimizelyClientTests_Valid.swift */, 6E7519BE22C5211100B2B157 /* OptimizelyClientTests_Invalid.swift */, @@ -4543,6 +4549,7 @@ 6E994B3A25A3E6EA00999262 /* DecisionResponse.swift in Sources */, 6E75170A22C520D400B2B157 /* OptimizelyClient.swift in Sources */, 6E9B11AC22C5489300C22D81 /* OTUtils.swift in Sources */, + 98137C572A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift in Sources */, 6E75191C22C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, 6E75180822C520D400B2B157 /* DataStoreFile.swift in Sources */, 6E7518EC22C520D400B2B157 /* ConditionHolder.swift in Sources */, @@ -4618,6 +4625,7 @@ 6E7518F822C520D500B2B157 /* UserAttribute.swift in Sources */, 6E7517A622C520D400B2B157 /* Array+Extension.swift in Sources */, 6E75191022C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, + 98137C552A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift in Sources */, 6E7516F222C520D400B2B157 /* OptimizelyError.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/Optimizely+Decide/OptimizelyUserContext.swift b/Sources/Optimizely+Decide/OptimizelyUserContext.swift index 2bf96f70a..7f7cf48e4 100644 --- a/Sources/Optimizely+Decide/OptimizelyUserContext.swift +++ b/Sources/Optimizely+Decide/OptimizelyUserContext.swift @@ -214,6 +214,29 @@ extension OptimizelyUserContext { } } + /// Fetch (non-blocking) all qualified segments for the user context. + /// + /// The segments fetched will be saved in **qualifiedSegments** and can be accessed any time. + /// On failure, **qualifiedSegments** will be nil and one of these errors will be thrown: + /// - OptimizelyError.invalidSegmentIdentifier + /// - OptimizelyError.fetchSegmentsFailed(String) + /// + /// - Parameters: + /// - options: A set of options for fetching qualified segments (optional). + /// - Throws: `OptimizelyError` if error is detected + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func fetchQualifiedSegments(options: [OptimizelySegmentOption] = []) async throws { + return try await withCheckedThrowingContinuation { continuation in + fetchQualifiedSegments { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + /// Fetch (blocking) all qualified segments for the user context. /// /// Note that this call will block the calling thread until fetching is completed. diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index b597474ef..38524deb5 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -154,6 +154,28 @@ open class OptimizelyClient: NSObject { } } + /// Start Optimizely SDK (async-await) + /// + /// If an updated datafile is available in the server, it's downloaded and the SDK is configured with + /// the updated datafile. + /// + /// - Parameters: + /// - resourceTimeout: timeout for datafile download (optional) + /// - Throws: `OptimizelyError` if error is detected + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func start(resourceTimeout: Double? = nil) async throws { + return try await withCheckedThrowingContinuation { continuation in + start(resourceTimeout: resourceTimeout) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + /// Start Optimizely SDK (Synchronous) /// /// - Parameters: diff --git a/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Init_Async_Await.swift b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Init_Async_Await.swift new file mode 100644 index 000000000..f1381ce5e --- /dev/null +++ b/Tests/OptimizelyTests-APIs/OptimizelyClientTests_Init_Async_Await.swift @@ -0,0 +1,116 @@ +// +// Copyright 2023, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +final class OptimizelyClientTests_Init_Async_Await: XCTestCase { + let kUserId = "11111" + let kExperimentKey = "exp_with_audience" + let kFlagKey = "feature_1" + let kVariationKey = "a" + let kRevisionUpdated = "34" + let kRevision = "241" + + func testInitAsyncAwait() async throws { + let testSdkKey = OTUtils.randomSdkKey // unique but consistent with registry + start + + let handler = FakeDatafileHandler(mode: .successWithData) + let optimizely = OptimizelyClient(sdkKey: testSdkKey, + datafileHandler: handler) + + try await optimizely.start() + let user = OptimizelyUserContext(optimizely: optimizely, userId: self.kUserId) + let decision = user.decide(key: self.kFlagKey) + + XCTAssert(decision.variationKey == self.kVariationKey) + } + + func testInitAsyncAwait_fetchError() async throws { + let testSdkKey = OTUtils.randomSdkKey // unique but consistent with registry + start + + let handler = FakeDatafileHandler(mode: .failure) + let optimizely = OptimizelyClient(sdkKey: testSdkKey, + datafileHandler: handler) + var _error: Error? + do { + try await optimizely.start() + } catch { + _error = error + } + + XCTAssertNotNil(_error) + } + + func testInitAsync_fetchNil_whenCacheLoadFailed() async { + let testSdkKey = OTUtils.randomSdkKey // unique but consistent with registry + start + + let handler = FakeDatafileHandler(mode: .failedToLoadFromCache) + let optimizely = OptimizelyClient(sdkKey: testSdkKey, + datafileHandler: handler) + + var _error: Error? + do { + try await optimizely.start() + } catch { + _error = error + } + + XCTAssertNotNil(_error) + } + +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension OptimizelyClientTests_Init_Async_Await { + + enum DataFileResponse { + case successWithData + case failedToLoadFromCache + case failure + } + + class FakeDatafileHandler: DefaultDatafileHandler { + var mode: DataFileResponse + var fileFlag: Bool = true + + init(mode: DataFileResponse) { + self.mode = mode + } + + required init() { + fatalError("init() has not been implemented") + } + + override func downloadDatafile(sdkKey: String, + returnCacheIfNoChange: Bool, + resourceTimeoutInterval: Double?, + completionHandler: @escaping DatafileDownloadCompletionHandler) { + + switch mode { + case .successWithData: + let filename = fileFlag ? OptimizelyClientTests_Init_Async.JSONfilename : OptimizelyClientTests_Init_Async.JSONfilenameUpdated + fileFlag.toggle() + + let data = OTUtils.loadJSONDatafile(filename) + completionHandler(.success(data)) + case .failedToLoadFromCache: + completionHandler(.success(nil)) + case .failure: + completionHandler(.failure(.dataFileInvalid)) + } + } + } +} diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_ODP_Aync_Await.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_ODP_Aync_Await.swift new file mode 100644 index 000000000..4b6e96773 --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_ODP_Aync_Await.swift @@ -0,0 +1,171 @@ +// +// Copyright 2023, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +final class OptimizelyUserContextTests_ODP_Aync_Await: XCTestCase { + var optimizely: OptimizelyClient! + var user: OptimizelyUserContext! + let datafile = OTUtils.loadJSONDatafile("decide_audience_segments")! + var odpManager: MockOdpManager! + + let kUserId = "tester" + let kUserKey = "custom_id" + let kUserValue = "custom_id_value" + let sdkKey = OTUtils.randomSdkKey + + override func setUp() { + odpManager = MockOdpManager(sdkKey: sdkKey, disable: false, cacheSize: 10, cacheTimeoutInSecs: 10) + + optimizely = OptimizelyClient(sdkKey: sdkKey) + optimizely.odpManager = odpManager + } + + // MARK: - fetchQualifiedSegments (non-blocking) + + // MARK: - Success + + func testFetchQualifiedSegments_successDefaultUser() async throws { + try? optimizely.start(datafile: datafile) + user = optimizely.createUserContext(userId: kUserId) + + var _error: OptimizelyError? + + do { + try await user.fetchQualifiedSegments() + } catch { + _error = error as? OptimizelyError + } + XCTAssertNil(_error) + XCTAssertEqual(self.user.qualifiedSegments, ["odp-segment-1"]) + } + + // MARK: - Failure + + func testFetchQualifiedSegments_sdkNotReady() async throws { + user = optimizely.createUserContext(userId: kUserId) + user.optimizely = nil + user.qualifiedSegments = ["dummy"] + + var _error: OptimizelyError? + + do { + try await user.fetchQualifiedSegments() + } catch { + _error = error as? OptimizelyError + } + XCTAssertEqual(OptimizelyError.sdkNotReady.reason, _error?.reason) + XCTAssertNil(self.user.qualifiedSegments) + + } + + func testFetchQualifiedSegments_fetchFailed() async throws { + user = optimizely.createUserContext(userId: kUserId) + user.qualifiedSegments = ["dummy"] + + var _error: OptimizelyError? + + do { + try await user.fetchQualifiedSegments() + } catch { + _error = error as? OptimizelyError + } + XCTAssertNotNil(_error) + XCTAssertNil(self.user.qualifiedSegments) + + } + + // MARK: - SegmentsToCheck + + func testFetchQualifiedSegments_segmentsToCheck_validAfterStart() async throws { + try? optimizely.start(datafile: datafile) + user = optimizely.createUserContext(userId: kUserId) + + try await user.fetchQualifiedSegments() + + XCTAssertEqual(Set(["odp-segment-1", "odp-segment-2", "odp-segment-3"]), Set(odpManager.odpConfig.segmentsToCheck)) + } + + func testFetchQualifiedSegments_segmentsNotUsed() async throws { + let datafile = OTUtils.loadJSONDatafile("odp_integrated_no_segments")! + try? optimizely.start(datafile: datafile) + user = optimizely.createUserContext(userId: kUserId) + + var _error: OptimizelyError? + + do { + try await user.fetchQualifiedSegments() + } catch { + _error = error as? OptimizelyError + } + XCTAssertNil(_error) + } + +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension OptimizelyUserContextTests_ODP_Aync_Await { + + // MARK: - MockOdpManager + + class MockOdpManager: OdpManager { + var userId: String? + var options: [OptimizelySegmentOption]! + var identifyCalled = false + + init(sdkKey: String, disable: Bool, cacheSize: Int, cacheTimeoutInSecs: Int) { + super.init(sdkKey: sdkKey, disable: disable, cacheSize: cacheSize, cacheTimeoutInSecs: cacheTimeoutInSecs) + self.segmentManager?.apiMgr = MockOdpSegmentApiManager() + } + + override func fetchQualifiedSegments(userId: String, + options: [OptimizelySegmentOption], + completionHandler: @escaping ([String]?, OptimizelyError?) -> Void) { + self.userId = userId + self.options = options + super.fetchQualifiedSegments(userId: userId, options: options, completionHandler: completionHandler) + } + + override func identifyUser(userId: String) { + self.userId = userId + self.identifyCalled = true + } + } + + // MARK: - MockOdpSegmentApiManager + + class MockOdpSegmentApiManager: OdpSegmentApiManager { + var receivedApiKey: String! + var receivedApiHost: String! + + override func fetchSegments(apiKey: String, + apiHost: String, + userKey: String, + userValue: String, + segmentsToCheck: [String], + completionHandler: @escaping ([String]?, OptimizelyError?) -> Void) { + receivedApiKey = apiKey + receivedApiHost = apiHost + + DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(1)) { + let qualified = segmentsToCheck.isEmpty ? [] : [segmentsToCheck.sorted{ $0 < $1 }.first!] + completionHandler(qualified, nil) + } + } + } +} +