diff --git a/.gitignore b/.gitignore index 1d6f9765..ff2a1abb 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ profile Carthage/Build/ MixpanelDemo/build/ + +# Claude.ai instructions +CLAUDE.md diff --git a/Mixpanel-swift.podspec b/Mixpanel-swift.podspec index d8845952..499ea524 100644 --- a/Mixpanel-swift.podspec +++ b/Mixpanel-swift.podspec @@ -19,9 +19,9 @@ Pod::Spec.new do |s| base_source_files = ['Sources/Network.swift', 'Sources/FlushRequest.swift', 'Sources/PrintLogging.swift', 'Sources/FileLogging.swift', 'Sources/MixpanelLogger.swift', 'Sources/JSONHandler.swift', 'Sources/Error.swift', 'Sources/AutomaticProperties.swift', 'Sources/Constants.swift', 'Sources/MixpanelType.swift', 'Sources/Mixpanel.swift', 'Sources/MixpanelInstance.swift', - 'Sources/Flush.swift','Sources/Track.swift', 'Sources/People.swift', 'Sources/AutomaticEvents.swift', - 'Sources/Group.swift', - 'Sources/ReadWriteLock.swift', 'Sources/SessionMetadata.swift', 'Sources/MPDB.swift', 'Sources/MixpanelPersistence.swift', 'Sources/Data+Compression.swift'] + 'Sources/Flush.swift', 'Sources/Track.swift', 'Sources/People.swift', 'Sources/AutomaticEvents.swift', + 'Sources/Group.swift', 'Sources/ReadWriteLock.swift', 'Sources/SessionMetadata.swift', 'Sources/MPDB.swift', 'Sources/MixpanelPersistence.swift', + 'Sources/Data+Compression.swift', 'Sources/MixpanelOptions.swift', 'Sources/FeatureFlags.swift'] s.tvos.deployment_target = '11.0' s.tvos.frameworks = 'UIKit', 'Foundation' s.tvos.pod_target_xcconfig = { diff --git a/Mixpanel.xcodeproj/project.pbxproj b/Mixpanel.xcodeproj/project.pbxproj index bda18251..4221c6e5 100644 --- a/Mixpanel.xcodeproj/project.pbxproj +++ b/Mixpanel.xcodeproj/project.pbxproj @@ -7,6 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + 171E4C122DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; + 171E4C132DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; + 171E4C142DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; + 171E4C152DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; + 171E4C172DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */; }; + 171E4C182DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */; }; + 171E4C192DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */; }; + 171E4C1A2DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */; }; 17C6547A2BB1F15C00C8A126 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */; }; 17C6547B2BB1F16000C8A126 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */; }; 17C6547C2BB1F16400C8A126 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */; }; @@ -103,6 +111,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; + 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixpanelOptions.swift; sourceTree = ""; }; 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sources/Mixpanel/PrivacyInfo.xcprivacy; sourceTree = SOURCE_ROOT; }; 51DD56791D306B740045D3DB /* MixpanelLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MixpanelLogger.swift; sourceTree = ""; }; 51DD56801D306B7B0045D3DB /* PrintLogging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrintLogging.swift; sourceTree = ""; }; @@ -226,12 +236,14 @@ E11594881CFF14D3007F8B4F /* Source */ = { isa = PBXGroup; children = ( + 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */, 17C654792BB1EF6700C8A126 /* Mixpanel */, E189D8FB1D5A6943007F3F29 /* Networking */, 51DD56771D306B620045D3DB /* Log */, E189D8FA1D5A692A007F3F29 /* Utilities */, E115948A1CFF1538007F8B4F /* Mixpanel.swift */, E115948D1D000709007F8B4F /* MixpanelInstance.swift */, + 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */, E115949E1D01BE14007F8B4F /* Flush.swift */, E11594A01D01C597007F8B4F /* Track.swift */, E15FF7C71D0435670076CDE3 /* People.swift */, @@ -488,6 +500,7 @@ 86F86EC722443A3C00B69832 /* FileLogging.swift in Sources */, 86F86EC622443A3100B69832 /* Error.swift in Sources */, 86F86EC522443A2C00B69832 /* People.swift in Sources */, + 171E4C172DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */, 86F86EC422443A2300B69832 /* ReadWriteLock.swift in Sources */, 8625BEBE26D045CE0009BAA9 /* MPDB.swift in Sources */, 95ECF06B2C9B851C006364D2 /* Data+Compression.swift in Sources */, @@ -495,6 +508,7 @@ 86F86EC122443A0E00B69832 /* JSONHandler.swift in Sources */, 86F86EC022443A0800B69832 /* MixpanelType.swift in Sources */, 86F86EBE224439FA00B69832 /* Network.swift in Sources */, + 171E4C142DAF108400B7CB11 /* FeatureFlags.swift in Sources */, 86F86EBD224439F500B69832 /* Flush.swift in Sources */, 86F86EBC224439F100B69832 /* PrintLogging.swift in Sources */, 868550AF2699096F001FCDDC /* MixpanelPersistence.swift in Sources */, @@ -517,6 +531,7 @@ E1D335CE1D30578E00E68E12 /* Constants.swift in Sources */, E115949F1D01BE14007F8B4F /* Flush.swift in Sources */, E11594971D006022007F8B4F /* Network.swift in Sources */, + 171E4C182DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */, E15FF7C81D0435670076CDE3 /* People.swift in Sources */, 673ABE3A21360CBE00B1784B /* Group.swift in Sources */, 95ECF0682C9B851A006364D2 /* Data+Compression.swift in Sources */, @@ -524,6 +539,7 @@ E11594991D01689F007F8B4F /* JSONHandler.swift in Sources */, E1D335D01D3059A800E68E12 /* AutomaticProperties.swift in Sources */, 51DD567C1D306B740045D3DB /* MixpanelLogger.swift in Sources */, + 171E4C122DAF108400B7CB11 /* FeatureFlags.swift in Sources */, E165228F1D6781DF000D5949 /* MixpanelType.swift in Sources */, BB9614171F3BB87700C3EF3E /* ReadWriteLock.swift in Sources */, E190522D1F9FC1BC00900E5D /* SessionMetadata.swift in Sources */, @@ -546,6 +562,7 @@ E12782BD1D4AB5CB0025FB05 /* MixpanelLogger.swift in Sources */, E12782BE1D4AB5CB0025FB05 /* Mixpanel.swift in Sources */, E12782BF1D4AB5CB0025FB05 /* MixpanelInstance.swift in Sources */, + 171E4C192DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */, E12782C11D4AB5CB0025FB05 /* Network.swift in Sources */, 8625BEBC26D045CE0009BAA9 /* MPDB.swift in Sources */, 95ECF0692C9B851B006364D2 /* Data+Compression.swift in Sources */, @@ -553,6 +570,7 @@ E12782C31D4AB5CB0025FB05 /* Flush.swift in Sources */, E12782C41D4AB5CB0025FB05 /* FlushRequest.swift in Sources */, E12782C51D4AB5CB0025FB05 /* Track.swift in Sources */, + 171E4C132DAF108400B7CB11 /* FeatureFlags.swift in Sources */, E12782C61D4AB5CB0025FB05 /* People.swift in Sources */, E19052001F9548F000900E5D /* ReadWriteLock.swift in Sources */, 868550AD2699096F001FCDDC /* MixpanelPersistence.swift in Sources */, @@ -575,6 +593,7 @@ E1F15FDC1E64B60A00391AE3 /* AutomaticProperties.swift in Sources */, E1F15FD91E64B60600391AE3 /* MixpanelLogger.swift in Sources */, E1F15FD61E64B5FC00391AE3 /* FlushRequest.swift in Sources */, + 171E4C1A2DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */, E1F15FD71E64B60200391AE3 /* PrintLogging.swift in Sources */, 8625BEBD26D045CE0009BAA9 /* MPDB.swift in Sources */, 95ECF06A2C9B851B006364D2 /* Data+Compression.swift in Sources */, @@ -582,6 +601,7 @@ E1F15FD51E64B5F800391AE3 /* Network.swift in Sources */, E1F15FDE1E64B60A00391AE3 /* MixpanelType.swift in Sources */, E1F15FDA1E64B60A00391AE3 /* JSONHandler.swift in Sources */, + 171E4C152DAF108400B7CB11 /* FeatureFlags.swift in Sources */, E1F15FE31E64B60D00391AE3 /* Track.swift in Sources */, E19052011F9548F000900E5D /* ReadWriteLock.swift in Sources */, 868550AE2699096F001FCDDC /* MixpanelPersistence.swift in Sources */, diff --git a/MixpanelDemo/MixpanelDemo.xcodeproj/project.pbxproj b/MixpanelDemo/MixpanelDemo.xcodeproj/project.pbxproj index 19faca82..48a337e4 100644 --- a/MixpanelDemo/MixpanelDemo.xcodeproj/project.pbxproj +++ b/MixpanelDemo/MixpanelDemo.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 171E4C1C2DB055BC00B7CB11 /* MixpanelFeatureFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C1B2DB055A900B7CB11 /* MixpanelFeatureFlagTests.swift */; }; 51DD568A1D3077390045D3DB /* LoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DD56891D3077390045D3DB /* LoggerTests.swift */; }; 60CB587123D77F9200F1632B /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60CB587023D77F9200F1632B /* LoginViewController.swift */; }; 671EECAF21432E5F006DD9FA /* GroupsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671EECAE21432E5F006DD9FA /* GroupsViewController.swift */; }; @@ -249,6 +250,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 171E4C1B2DB055A900B7CB11 /* MixpanelFeatureFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixpanelFeatureFlagTests.swift; sourceTree = ""; }; 51DD56891D3077390045D3DB /* LoggerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggerTests.swift; sourceTree = ""; }; 60CB587023D77F9200F1632B /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; 671EECAE21432E5F006DD9FA /* GroupsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupsViewController.swift; sourceTree = ""; }; @@ -590,6 +592,7 @@ E15FF7EA1D0461130076CDE3 /* MixpanelDemoTests */ = { isa = PBXGroup; children = ( + 171E4C1B2DB055A900B7CB11 /* MixpanelFeatureFlagTests.swift */, E124061F1D249B2500383635 /* MixpanelBaseTests.swift */, E15FF7EB1D0461130076CDE3 /* MixpanelDemoTests.swift */, E1C61EB91D22F6470056C56C /* MixpanelPeopleTests.swift */, @@ -1114,6 +1117,7 @@ E12406201D249B2500383635 /* MixpanelBaseTests.swift in Sources */, E17AA05E1EC6234E0066EFE8 /* MixpanelAutomaticEventsTests.swift in Sources */, E15FF7EC1D0461130076CDE3 /* MixpanelDemoTests.swift in Sources */, + 171E4C1C2DB055BC00B7CB11 /* MixpanelFeatureFlagTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MixpanelDemo/MixpanelDemo/AppDelegate.swift b/MixpanelDemo/MixpanelDemo/AppDelegate.swift index 73607c9c..a2906a53 100644 --- a/MixpanelDemo/MixpanelDemo/AppDelegate.swift +++ b/MixpanelDemo/MixpanelDemo/AppDelegate.swift @@ -17,7 +17,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { var ADD_YOUR_MIXPANEL_TOKEN_BELOW_🛠🛠🛠🛠🛠🛠: String - Mixpanel.initialize(token: "MIXPANEL_TOKEN", trackAutomaticEvents: true) + let mixpanelOptions = MixpanelOptions(token: "MIXPANEL_TOKEN", trackAutomaticEvents: true) + Mixpanel.initialize(options: mixpanelOptions) Mixpanel.mainInstance().loggingEnabled = true return true diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift index 36052d58..5b3d7efc 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift @@ -179,8 +179,51 @@ class MixpanelDemoTests: MixpanelBaseTests { removeDBfile(testMixpanel.apiToken) } + // Mock implementation of MixpanelFlags to track loadFlags calls + class MockMixpanelFlags: MixpanelFlags { + var delegate: MixpanelFlagDelegate? + var loadFlagsCallCount = 0 + + func loadFlags() { + loadFlagsCallCount += 1 + } + + func areFlagsReady() -> Bool { + return true + } + + func getVariantSync(_ flagName: String, fallback: MixpanelFlagVariant) -> MixpanelFlagVariant { + return fallback + } + + func getVariant(_ flagName: String, fallback: MixpanelFlagVariant, completion: @escaping (MixpanelFlagVariant) -> Void) { + completion(fallback) + } + + func getVariantValueSync(_ flagName: String, fallbackValue: Any?) -> Any? { + return fallbackValue + } + + func getVariantValue(_ flagName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) { + completion(fallbackValue) + } + + func isEnabledSync(_ flagName: String, fallbackValue: Bool) -> Bool { + return fallbackValue + } + + func isEnabled(_ flagName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void) { + completion(fallbackValue) + } + } + func testIdentify() { let testMixpanel = Mixpanel.initialize(token: randomId(), trackAutomaticEvents: true, flushInterval: 60) + + // Inject our mock flags object + let mockFlags = MockMixpanelFlags() + testMixpanel.flags = mockFlags + for _ in 0..<2 { // run this twice to test reset works correctly wrt to distinct ids let distinctId: String = "d1" @@ -221,8 +264,16 @@ class MixpanelDemoTests: MixpanelBaseTests { XCTAssertEqual(unidentifiedQueue.last?["$token"] as? String, testMixpanel.apiToken, "incorrect project token in people record") + // Record the loadFlags call count before identify + let loadFlagsCallCountBefore = mockFlags.loadFlagsCallCount + testMixpanel.identify(distinctId: distinctId) waitForTrackingQueue(testMixpanel) + + // Assert that loadFlags was called when distinctId changed + XCTAssertEqual(mockFlags.loadFlagsCallCount, loadFlagsCallCountBefore + 1, + "loadFlags should be called when distinctId changes during identify") + let anonymousId = testMixpanel.anonymousId peopleQueue_value = peopleQueue(token: testMixpanel.apiToken) unidentifiedQueue = unIdentifiedPeopleQueue(token: testMixpanel.apiToken) @@ -263,6 +314,14 @@ class MixpanelDemoTests: MixpanelBaseTests { let newDistinctId = (eventQueue(token: testMixpanel.apiToken).last?["properties"] as? InternalProperties)?["distinct_id"] as? String XCTAssertEqual(newDistinctId, distinctId, "events should use new distinct id after identify:") + + // Test that calling identify with the same distinctId does NOT trigger loadFlags + let loadFlagsCountBeforeSameId = mockFlags.loadFlagsCallCount + testMixpanel.identify(distinctId: distinctId) // Same distinctId + waitForTrackingQueue(testMixpanel) + XCTAssertEqual(mockFlags.loadFlagsCallCount, loadFlagsCountBeforeSameId, + "loadFlags should NOT be called when distinctId doesn't change") + testMixpanel.reset() waitForTrackingQueue(testMixpanel) } diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift new file mode 100644 index 00000000..8b68a43f --- /dev/null +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -0,0 +1,840 @@ +// +// MixpanelFeatureFlagTests.swift +// MixpanelDemo +// +// Created by Jared McFarland on 4/16/25. +// Copyright © 2025 Mixpanel. All rights reserved. +// + +import XCTest +@testable import Mixpanel + +// MARK: - Mocks and Helpers (Largely Unchanged) + +class MockFeatureFlagDelegate: MixpanelFlagDelegate { + + var options: MixpanelOptions + var distinctId: String + var trackedEvents: [(event: String?, properties: Properties?)] = [] + var trackExpectation: XCTestExpectation? + var getOptionsCallCount = 0 + var getDistinctIdCallCount = 0 + + init(options: MixpanelOptions = MixpanelOptions(token: "test", featureFlagsEnabled: true), distinctId: String = "test_distinct_id") { + self.options = options + self.distinctId = distinctId + } + + func getOptions() -> MixpanelOptions { + getOptionsCallCount += 1 + return options + } + + func getDistinctId() -> String { + getDistinctIdCallCount += 1 + return distinctId + } + + func track(event: String?, properties: Properties?) { + print("MOCK Delegate: Track called - Event: \(event ?? "nil"), Props: \(properties ?? [:])") + trackedEvents.append((event: event, properties: properties)) + trackExpectation?.fulfill() + } +} + +// AssertEqual helper (Unchanged from previous working version) +func AssertEqual(_ value1: Any?, _ value2: Any?, file: StaticString = #file, line: UInt = #line) { + // ... (Use the version that fixed the Any?? issues) ... + switch (value1, value2) { + case (nil, nil): + break // Equal + case (let v1 as Bool, let v2 as Bool): + XCTAssertEqual(v1, v2, file: file, line: line) + case (let v1 as String, let v2 as String): + XCTAssertEqual(v1, v2, file: file, line: line) + case (let v1 as Int, let v2 as Int): + XCTAssertEqual(v1, v2, file: file, line: line) + case (let v1 as Double, let v2 as Double): + // Handle potential precision issues if necessary + XCTAssertEqual(v1, v2, accuracy: 0.00001, file: file, line: line) + case (let v1 as [Any?], let v2 as [Any?]): + XCTAssertEqual(v1.count, v2.count, "Array counts differ", file: file, line: line) + for (index, item1) in v1.enumerated() { + guard index < v2.count else { + XCTFail("Index \(index) out of bounds for second array", file: file, line: line) + return + } + AssertEqual(item1, v2[index], file: file, line: line) + } + case (let v1 as [String: Any?], let v2 as [String: Any?]): + XCTAssertEqual(v1.count, v2.count, "Dictionary counts differ (\(v1.keys.sorted()) vs \(v2.keys.sorted()))", file: file, line: line) + for (key, item1) in v1 { + guard v2.keys.contains(key) else { + XCTFail("Key '\(key)' missing in second dictionary", file: file, line: line) + continue + } + let item2DoubleOptional = v2[key] + AssertEqual(item1, item2DoubleOptional ?? nil, file: file, line: line) + } + default: + if let n1 = value1 as? NSNumber, let n2 = value2 as? NSNumber { + XCTAssertEqual(n1, n2, "NSNumber values differ: \(n1) vs \(n2)", file: file, line: line) + } else { + XCTFail("Values are not equal or of comparable types: \(String(describing: value1)) vs \(String(describing: value2))", file: file, line: line) + } + } +} + + +// MARK: - Refactored FeatureFlagManager Tests + +class FeatureFlagManagerTests: XCTestCase { + + var mockDelegate: MockFeatureFlagDelegate! + var manager: FeatureFlagManager! + // Sample flag data for simulating fetch results + let sampleFlags: [String: MixpanelFlagVariant] = [ + "feature_bool_true": MixpanelFlagVariant(key: "v_true", value: true), + "feature_bool_false": MixpanelFlagVariant(key: "v_false", value: false), + "feature_string": MixpanelFlagVariant(key: "v_str", value: "test_string"), + "feature_int": MixpanelFlagVariant(key: "v_int", value: 101), + "feature_double": MixpanelFlagVariant(key: "v_double", value: 99.9), + "feature_null": MixpanelFlagVariant(key: "v_null", value: nil) + ] + let defaultFallback = MixpanelFlagVariant(value: nil) // Default fallback for convenience + + override func setUpWithError() throws { + try super.setUpWithError() + mockDelegate = MockFeatureFlagDelegate() + // Ensure manager is initialized with the delegate + manager = FeatureFlagManager(serverURL: "https://test.com", delegate: mockDelegate) + } + + override func tearDownWithError() throws { + mockDelegate = nil + manager = nil + try super.tearDownWithError() + } + + // --- Simulation Helpers --- + // These now directly modify state and call the *internal* _completeFetch + // Requires _completeFetch to be accessible (e.g., internal or @testable import) + + private func simulateFetchSuccess(flags: [String: MixpanelFlagVariant]? = nil) { + let flagsToSet = flags ?? sampleFlags + // Set flags directly *before* calling completeFetch + manager.accessQueue.sync { + manager.flags = flagsToSet + // Important: Set isFetching = true *before* calling _completeFetch, + // as _completeFetch assumes a fetch was in progress. + manager.isFetching = true + } + // Call internal completion logic + manager._completeFetch(success: true) + } + + private func simulateFetchFailure() { + // Set isFetching = true before calling _completeFetch + manager.accessQueue.sync { + manager.isFetching = true + // Ensure flags are nil or unchanged on failure simulation if desired + manager.flags = nil // Or keep existing flags based on desired failure behavior + } + // Call internal completion logic + manager._completeFetch(success: false) + } + + // --- State and Configuration Tests --- + + func testAreFeaturesReady_InitialState() { + XCTAssertFalse(manager.areFlagsReady(), "Features should not be ready initially") + } + + func testAreFeaturesReady_AfterSuccessfulFetchSimulation() { + simulateFetchSuccess() + // Need to wait briefly for the main queue dispatch in _completeFetch to potentially run + let expectation = XCTestExpectation(description: "Wait for potential completion dispatch") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } + wait(for: [expectation], timeout: 0.5) + XCTAssertTrue(manager.areFlagsReady(), "Features should be ready after successful fetch simulation") + } + + func testAreFeaturesReady_AfterFailedFetchSimulation() { + simulateFetchFailure() + // Need to wait briefly for the main queue dispatch in _completeFetch to potentially run + let expectation = XCTestExpectation(description: "Wait for potential completion dispatch") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } + wait(for: [expectation], timeout: 0.5) + XCTAssertFalse(manager.areFlagsReady(), "Features should not be ready after failed fetch simulation") + } + + // --- Load Flags Tests --- + + func testLoadFlags_WhenDisabledInConfig() { + mockDelegate.options = MixpanelOptions(token:"test", featureFlagsEnabled: false) // Explicitly disable + manager.loadFlags() // Call public API + + // Wait to ensure no async fetch operations started changing state + let expectation = XCTestExpectation(description: "Wait briefly") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } + wait(for: [expectation], timeout: 0.5) + + XCTAssertFalse(manager.areFlagsReady(), "Flags should not become ready if disabled") + // We can't easily check if _fetchFlagsIfNeeded was *not* called without more testability hooks + } + + // Note: Testing that loadFlags *starts* a fetch is harder now without exposing internal state. + // We test the outcome via the async getFeature tests below. + + // --- Sync Flag Retrieval Tests --- + + func testGetVariantSync_FlagsReady_ExistingFlag() { + simulateFetchSuccess() // Flags loaded + let flagVariant = manager.getVariantSync("feature_string", fallback: defaultFallback) + AssertEqual(flagVariant.key, "v_str") + AssertEqual(flagVariant.value, "test_string") + // Tracking check happens later + } + + func testGetVariantSync_FlagsReady_MissingFlag_UsesFallback() { + simulateFetchSuccess() + let fallback = MixpanelFlagVariant(key: "fb_key", value: "fb_value") + let flagVariant = manager.getVariantSync("missing_feature", fallback: fallback) + AssertEqual(flagVariant.key, fallback.key) + AssertEqual(flagVariant.value, fallback.value) + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track for fallback") + } + + func testGetVariantSync_FlagsNotReady_UsesFallback() { + XCTAssertFalse(manager.areFlagsReady()) // Precondition + let fallback = MixpanelFlagVariant(key: "fb_key", value: 999) + let flagVariant = manager.getVariantSync("feature_bool_true", fallback: fallback) + AssertEqual(flagVariant.key, fallback.key) + AssertEqual(flagVariant.value, fallback.value) + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track if flags not ready") + } + + func testGetVariantValueSync_FlagsReady() { + simulateFetchSuccess() + let value = manager.getVariantValueSync("feature_int", fallbackValue: -1) + AssertEqual(value, 101) + } + + func testGetVariantValueSync_FlagsReady_MissingFlag() { + simulateFetchSuccess() + let value = manager.getVariantValueSync("missing_feature", fallbackValue: "default") + AssertEqual(value, "default") + } + + func testGetVariantValueSync_FlagsNotReady() { + XCTAssertFalse(manager.areFlagsReady()) + let value = manager.getVariantValueSync("feature_int", fallbackValue: -1) + AssertEqual(value, -1) + } + + func testIsFlagEnabledSync_FlagsReady_True() { + simulateFetchSuccess() + XCTAssertTrue(manager.isEnabledSync("feature_bool_true")) + } + + func testIsFlagEnabledSync_FlagsReady_False() { + simulateFetchSuccess() + XCTAssertFalse(manager.isEnabledSync("feature_bool_false")) + } + + func testIsFlagEnabledSync_FlagsReady_MissingFlag_UsesFallback() { + simulateFetchSuccess() + XCTAssertTrue(manager.isEnabledSync("missing", fallbackValue: true)) + XCTAssertFalse(manager.isEnabledSync("missing", fallbackValue: false)) + } + + func testIsFlagEnabledSync_FlagsReady_NonBoolValue_UsesFallback() { + simulateFetchSuccess() + XCTAssertTrue(manager.isEnabledSync("feature_string", fallbackValue: true)) // String value + XCTAssertFalse(manager.isEnabledSync("feature_int", fallbackValue: false)) // Int value + XCTAssertTrue(manager.isEnabledSync("feature_null", fallbackValue: true)) // Null value + } + + func testIsFlagEnabledSync_FlagsNotReady_UsesFallback() { + XCTAssertFalse(manager.areFlagsReady()) + XCTAssertTrue(manager.isEnabledSync("feature_bool_true", fallbackValue: true)) + XCTAssertFalse(manager.isEnabledSync("feature_bool_true", fallbackValue: false)) + } + + // --- Async Flag Retrieval Tests --- + + func testGetVariant_Async_FlagsReady_ExistingFlag_XCTWaiter() { + // Arrange + simulateFetchSuccess() // Ensure flags are ready + let expectation = XCTestExpectation(description: "Async getFeature ready - XCTWaiter Wait") + var receivedData: MixpanelFlagVariant? + var assertionError: String? + + // Act + manager.getVariant("feature_double", fallback: defaultFallback) { data in + // This completion should run on the main thread + if !Thread.isMainThread { assertionError = "Completion not on main thread (\(Thread.current))" } + receivedData = data + // Perform crucial checks inside completion + if receivedData == nil { assertionError = (assertionError ?? "") + "; Received data was nil" } + if receivedData?.key != "v_double" { assertionError = (assertionError ?? "") + "; Received key mismatch" } + // Add other essential checks if needed + expectation.fulfill() + } + + // Assert - Wait using an explicit XCTWaiter instance + let waiter = XCTWaiter() + let result = waiter.wait(for: [expectation], timeout: 2.0) // Increased timeout + + // Check waiter result and any errors captured in completion + if result != .completed { + XCTFail("XCTWaiter timed out waiting for expectation. Error captured: \(assertionError ?? "None")") + } else if let error = assertionError { + XCTFail("Assertions failed within completion block: \(error)") + } + + // Final check on data after wait + // These might be redundant if checked thoroughly in completion, but good final check + XCTAssertNotNil(receivedData, "Received data should be non-nil after successful wait") + AssertEqual(receivedData?.key, "v_double") + AssertEqual(receivedData?.value, 99.9) + } + + func testGetVariant_Async_FlagsReady_MissingFlag_UsesFallback() { + simulateFetchSuccess() // Flags loaded + let expectation = XCTestExpectation(description: "Async getFeature (Flags Ready, Missing) completes") + let fallback = MixpanelFlagVariant(key: "fb_async", value: -1) + var receivedData: MixpanelFlagVariant? + + manager.getVariant("missing_feature", fallback: fallback) { data in + XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") + receivedData = data + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + XCTAssertNotNil(receivedData) + AssertEqual(receivedData?.key, fallback.key) + AssertEqual(receivedData?.value, fallback.value) + // Check delegate tracking after wait (should not have tracked) + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track fallback") + } + + // Test fetch triggering and completion via getFeature when not ready + func testGetVariant_Async_FlagsNotReady_FetchSuccess() { + XCTAssertFalse(manager.areFlagsReady()) + let expectation = XCTestExpectation(description: "Async getFeature (Flags Not Ready) triggers fetch and succeeds") + var receivedData: MixpanelFlagVariant? + + // Setup tracking expectation *before* calling getFeature + mockDelegate.trackExpectation = XCTestExpectation(description: "Tracking call for fetch success") + + // Call getFeature - this should trigger the fetch logic internally + manager.getVariant("feature_int", fallback: defaultFallback) { data in + XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") + receivedData = data + expectation.fulfill() // Fulfill main expectation + } + + // Crucially, simulate the fetch success *after* getFeature was called. + // Add a slight delay to mimic network latency and allow fetch logic to start. + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + print("Simulating fetch success...") + self.simulateFetchSuccess() // This sets flags and calls _completeFetch + } + + // Wait for BOTH the getFeature completion AND the tracking expectation + wait(for: [expectation, mockDelegate.trackExpectation!], timeout: 3.0) // Increased timeout + + XCTAssertNotNil(receivedData) + AssertEqual(receivedData?.key, "v_int") // Check correct flag data received + AssertEqual(receivedData?.value, 101) + XCTAssertTrue(manager.areFlagsReady(), "Flags should be ready after successful fetch") + XCTAssertEqual(mockDelegate.trackedEvents.count, 1, "Tracking event should have been recorded") + } + + func testGetVariant_Async_FlagsNotReady_FetchFailure() { + XCTAssertFalse(manager.areFlagsReady()) + let expectation = XCTestExpectation(description: "Async getFeature (Flags Not Ready) triggers fetch and fails") + let fallback = MixpanelFlagVariant(key:"fb_fail", value: "failed_fetch") + var receivedData: MixpanelFlagVariant? + + // Call getFeature + manager.getVariant("feature_string", fallback: fallback) { data in + XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") + receivedData = data + expectation.fulfill() + } + + // Simulate fetch failure after a delay + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + print("Simulating fetch failure...") + self.simulateFetchFailure() // This calls _completeFetch(success: false) + } + + wait(for: [expectation], timeout: 3.0) + + XCTAssertNotNil(receivedData) + AssertEqual(receivedData?.key, fallback.key) // Should receive fallback + AssertEqual(receivedData?.value, fallback.value) + XCTAssertFalse(manager.areFlagsReady(), "Flags should still not be ready after failed fetch") + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track on fetch failure/fallback") + } + + + // --- Tracking Tests --- + + func testTracking_CalledOncePerFeature() { + simulateFetchSuccess() // Flags ready + + mockDelegate.trackExpectation = XCTestExpectation(description: "Track called once for feature_bool_true") + mockDelegate.trackExpectation?.expectedFulfillmentCount = 1 // Expect exactly one call + + // Call sync methods multiple times + _ = manager.getVariantSync("feature_bool_true", fallback: defaultFallback) + _ = manager.getVariantValueSync("feature_bool_true", fallbackValue: nil) + _ = manager.isEnabledSync("feature_bool_true") + + // Call async method + let asyncExpectation = XCTestExpectation(description: "Async getFeature completes for tracking test") + manager.getVariant("feature_bool_true", fallback: defaultFallback) { _ in asyncExpectation.fulfill() } + + // Wait for async call AND the track expectation + wait(for: [asyncExpectation, mockDelegate.trackExpectation!], timeout: 2.0) + + // Verify track delegate method was called exactly once + let trueEvents = mockDelegate.trackedEvents.filter { $0.properties?["Experiment name"] as? String == "feature_bool_true" } + XCTAssertEqual(trueEvents.count, 1, "Track should only be called once for the same feature") + + // --- Call for a *different* feature --- + mockDelegate.trackExpectation = XCTestExpectation(description: "Track called for feature_string") + _ = manager.getVariantSync("feature_string", fallback: defaultFallback) + wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) + + let stringEvents = mockDelegate.trackedEvents.filter { $0.properties?["Experiment name"] as? String == "feature_string" } + XCTAssertEqual(stringEvents.count, 1, "Track should be called again for a different feature") + + // Verify total calls + XCTAssertEqual(mockDelegate.trackedEvents.count, 2, "Total track calls should be 2") + } + + func testTracking_SendsCorrectProperties() { + simulateFetchSuccess() + mockDelegate.trackExpectation = XCTestExpectation(description: "Track called for properties check") + + _ = manager.getVariantSync("feature_int", fallback: defaultFallback) // Trigger tracking + + wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) + + XCTAssertEqual(mockDelegate.trackedEvents.count, 1) + let tracked = mockDelegate.trackedEvents[0] + XCTAssertEqual(tracked.event, "$experiment_started") + XCTAssertNotNil(tracked.properties) + + let props = tracked.properties! + AssertEqual(props["Experiment name"] ?? nil, "feature_int") + AssertEqual(props["Variant name"] ?? nil, "v_int") + AssertEqual(props["$experiment_type"] ?? nil, "feature_flag") + } + + func testTracking_DoesNotTrackForFallback_Sync() { + simulateFetchSuccess() // Flags ready + _ = manager.getVariantSync("missing_feature", fallback: MixpanelFlagVariant(key:"fb", value:"v")) // Request missing flag + // Wait briefly to ensure no unexpected tracking call + let expectation = XCTestExpectation(description: "Wait briefly for no track") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } + wait(for: [expectation], timeout: 0.5) + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Track should not be called when a fallback is used (sync)") + } + + func testTracking_DoesNotTrackForFallback_Async() { + simulateFetchSuccess() // Flags ready + let expectation = XCTestExpectation(description: "Async getFeature (Fallback) completes") + + manager.getVariant("missing_feature", fallback: MixpanelFlagVariant(key:"fb", value:"v")) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + // Check delegate tracking after wait + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Track should not be called when a fallback is used (async)") + } + + // --- Concurrency Tests --- + + // Test concurrent fetch attempts (via getFeature when not ready) + func testConcurrentGetFeature_WhenNotReady_OnlyOneFetch() { + XCTAssertFalse(manager.areFlagsReady()) + + let numConcurrentCalls = 5 + var expectations: [XCTestExpectation] = [] + var completionResults: [MixpanelFlagVariant?] = Array(repeating: nil, count: numConcurrentCalls) + + // Expect tracking only ONCE for the actual feature if fetch succeeds + mockDelegate.trackExpectation = XCTestExpectation(description: "Track call (should be once)") + mockDelegate.trackExpectation?.expectedFulfillmentCount = 1 + + print("Starting \(numConcurrentCalls) concurrent getFeature calls...") + for i in 0.. FlagsResponse? = { data in + do { + return try JSONDecoder().decode(FlagsResponse.self, from: data) + } catch { + print("Error parsing flags JSON: \(error)") + return nil + } + } + + // Create various test data scenarios + let validJSON = """ + { + "flags": { + "test_flag": { + "variant_key": "test_variant", + "variant_value": "test_value" + } + } + } + """.data(using: .utf8)! + + let emptyFlagsJSON = """ + { + "flags": {} + } + """.data(using: .utf8)! + + let nullFlagsJSON = """ + { + "flags": null + } + """.data(using: .utf8)! + + let malformedJSON = "not json".data(using: .utf8)! + + // Test valid JSON with flags + let validResult = parseResponse(validJSON) + XCTAssertNotNil(validResult, "Parser should handle valid JSON") + XCTAssertNotNil(validResult?.flags, "Flags should be non-nil") + XCTAssertEqual(validResult?.flags?.count, 1, "Should have one flag") + XCTAssertEqual(validResult?.flags?["test_flag"]?.key, "test_variant") + XCTAssertEqual(validResult?.flags?["test_flag"]?.value as? String, "test_value") + + // Test empty flags object + let emptyResult = parseResponse(emptyFlagsJSON) + XCTAssertNotNil(emptyResult, "Parser should handle empty flags object") + XCTAssertNotNil(emptyResult?.flags, "Flags should be non-nil") + XCTAssertEqual(emptyResult?.flags?.count, 0, "Flags should be empty") + + // Test null flags field + let nullResult = parseResponse(nullFlagsJSON) + XCTAssertNotNil(nullResult, "Parser should handle null flags") + XCTAssertNil(nullResult?.flags, "Flags should be nil when null in JSON") + + // Test malformed JSON + let malformedResult = parseResponse(malformedJSON) + XCTAssertNil(malformedResult, "Parser should return nil for malformed JSON") + + // Test with multiple flags + let multipleFlagsJSON = """ + { + "flags": { + "feature_a": { + "variant_key": "variant_a", + "variant_value": true + }, + "feature_b": { + "variant_key": "variant_b", + "variant_value": 42 + }, + "feature_c": { + "variant_key": "variant_c", + "variant_value": null + } + } + } + """.data(using: .utf8)! + + let multiResult = parseResponse(multipleFlagsJSON) + XCTAssertNotNil(multiResult, "Parser should handle multiple flags") + XCTAssertEqual(multiResult?.flags?.count, 3, "Should have three flags") + XCTAssertEqual(multiResult?.flags?["feature_a"]?.value as? Bool, true) + XCTAssertEqual(multiResult?.flags?["feature_b"]?.value as? Int, 42) + XCTAssertNil(multiResult?.flags?["feature_c"]?.value, "Null value should be preserved") + + // Test with missing required fields + let missingFieldJSON = """ + { + "not_flags": {} + } + """.data(using: .utf8)! + + let missingFieldResult = parseResponse(missingFieldJSON) + XCTAssertNotNil(missingFieldResult, "Parser should handle missing flags field") + XCTAssertNil(missingFieldResult?.flags, "Flags should be nil when field is missing") + } + + // --- Delegate Error Handling Tests --- + + func testDelegateNilHandling() { + // Set up with flags ready, but then remove delegate + simulateFetchSuccess() + manager.delegate = nil + + // Test all operations with nil delegate + + // Synchronous operations + let syncData = manager.getVariantSync("feature_bool_true", fallback: defaultFallback) + XCTAssertEqual(syncData.key, "v_true") + XCTAssertEqual(syncData.value as? Bool, true) + + // Async operations + let expectation = XCTestExpectation(description: "Async with nil delegate") + manager.getVariant("feature_int", fallback: defaultFallback) { data in + XCTAssertEqual(data.key, "v_int") + XCTAssertEqual(data.value as? Int, 101) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // No tracking calls should succeed, but operations should still work + // This is "success" as the code doesn't crash when delegate is nil + } + + func testFetchWithNoDelegate() { + // Create manager with no delegate + let noDelegate = FeatureFlagManager(serverURL: "https://test.com", delegate: nil) + + // Try to load flags + noDelegate.loadFlags() + + // Verify no crash; attempt a flag fetch after a short delay + let expectation = XCTestExpectation(description: "Check after attempted fetch") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertFalse(noDelegate.areFlagsReady(), "Flags should not be ready without delegate") + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testDelegateConfigDisabledHandling() { + // Set delegate options to disabled + mockDelegate.options = MixpanelOptions(token: "test", featureFlagsEnabled: false) + + // Try to load flags + manager.loadFlags() + + // Verify no fetch is triggered + let expectation = XCTestExpectation(description: "Check disabled options behavior") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertFalse(self.manager.areFlagsReady(), "Flags should not be ready when options disabled") + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + // --- AnyCodable Edge Cases --- + + func testAnyCodableWithComplexTypes() { + // Use reflection to test AnyCodable directly + + // Test with nested array + let nestedArrayJSON = """ + { + "variant_key": "complex_array", + "variant_value": [1, "string", true, [2, 3], {"key": "value"}] + } + """.data(using: .utf8)! + + do { + let decoder = JSONDecoder() + let flagData = try decoder.decode(MixpanelFlagVariant.self, from: nestedArrayJSON) + + XCTAssertEqual(flagData.key, "complex_array") + XCTAssertNotNil(flagData.value, "Value should not be nil") + + // Verify array structure + guard let array = flagData.value as? [Any?] else { + XCTFail("Value should be an array") + return + } + + XCTAssertEqual(array.count, 5, "Array should have 5 elements") + XCTAssertEqual(array[0] as? Int, 1) + XCTAssertEqual(array[1] as? String, "string") + XCTAssertEqual(array[2] as? Bool, true) + + // Nested array check + guard let nestedArray = array[3] as? [Any?] else { + XCTFail("Element 3 should be an array") + return + } + XCTAssertEqual(nestedArray.count, 2) + XCTAssertEqual(nestedArray[0] as? Int, 2) + XCTAssertEqual(nestedArray[1] as? Int, 3) + + // Nested dictionary check + guard let nestedDict = array[4] as? [String: Any?] else { + XCTFail("Element 4 should be a dictionary") + return + } + XCTAssertEqual(nestedDict.count, 1) + XCTAssertEqual(nestedDict["key"] as? String, "value") + + } catch { + XCTFail("Failed to decode nested array JSON: \(error)") + } + + // Test with deeply nested object + let nestedObjectJSON = """ + { + "variant_key": "complex_object", + "variant_value": { + "str": "value", + "num": 42, + "bool": true, + "null": null, + "array": [1, 2], + "nested": { + "deeper": { + "deepest": "bottom" + } + } + } + } + """.data(using: .utf8)! + + do { + let decoder = JSONDecoder() + let flagData = try decoder.decode(MixpanelFlagVariant.self, from: nestedObjectJSON) + + XCTAssertEqual(flagData.key, "complex_object") + XCTAssertNotNil(flagData.value, "Value should not be nil") + + // Verify dictionary structure + guard let dict = flagData.value as? [String: Any?] else { + XCTFail("Value should be a dictionary") + return + } + + XCTAssertEqual(dict.count, 6, "Dictionary should have 6 keys") + XCTAssertEqual(dict["str"] as? String, "value") + XCTAssertEqual(dict["num"] as? Int, 42) + XCTAssertEqual(dict["bool"] as? Bool, true) + XCTAssertTrue(dict.keys.contains("null"), "Key 'null' should exist") + if let nullEntry = dict["null"] { + // Key exists with a value of nil (as wanted) + XCTAssertNil(nullEntry, "Value for null key should be nil") + } else { + // Key doesn't exist (which would be wrong) + XCTFail("'null' key should exist in dictionary") + } + + // Check nested array + guard let array = dict["array"] as? [Any?] else { + XCTFail("Array key should contain an array") + return + } + XCTAssertEqual(array.count, 2) + + // Check deeply nested structure + guard let nested = dict["nested"] as? [String: Any?] else { + XCTFail("Nested key should contain dictionary") + return + } + + guard let deeper = nested["deeper"] as? [String: Any?] else { + XCTFail("Deeper key should contain dictionary") + return + } + + XCTAssertEqual(deeper["deepest"] as? String, "bottom") + + } catch { + XCTFail("Failed to decode nested object JSON: \(error)") + } + } + + func testAnyCodableWithInvalidTypes() { + // Test case where variant_value has an unsupported type + // Note: This is harder to test directly since JSON doesn't have many "invalid" types + // We can test error handling by constructing invalid JSON manually + + let unsupportedTypeJSON = """ + { + "variant_key": "invalid_type", + "variant_value": "infinity" + } + """.data(using: .utf8)! + + // This is a valid test since the string will decode properly + do { + let decoder = JSONDecoder() + let flagData = try decoder.decode(MixpanelFlagVariant.self, from: unsupportedTypeJSON) + XCTAssertEqual(flagData.key, "invalid_type") + XCTAssertEqual(flagData.value as? String, "infinity") + } catch { + XCTFail("Should not fail with simple string value: \(error)") + } + + // Test handling of missing variant_value + let missingValueJSON = """ + { + "variant_key": "missing_value" + } + """.data(using: .utf8)! + + do { + let decoder = JSONDecoder() + let _ = try decoder.decode(MixpanelFlagVariant.self, from: missingValueJSON) + XCTFail("Decoding should fail with missing variant_value") + } catch { + // This is expected to fail, so the test passes + XCTAssertTrue(error is DecodingError, "Error should be a DecodingError") + } + } + +} // End Test Class diff --git a/README.md b/README.md index b1909f78..75639350 100644 --- a/README.md +++ b/README.md @@ -148,5 +148,7 @@ No worries, here are some links that you will find useful: * **[Sample app](https://github.com/mixpanel/mixpanel-swift/tree/master/MixpanelDemo)** * **[Full API Reference](https://mixpanel.github.io/mixpanel-swift)** +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mixpanel/mixpanel-swift) + Have any questions? Reach out to Mixpanel [Support](https://help.mixpanel.com/hc/en-us/requests/new) to speak to someone smart, quickly. diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift new file mode 100644 index 00000000..68be3605 --- /dev/null +++ b/Sources/FeatureFlags.swift @@ -0,0 +1,489 @@ +import Foundation + +// Wrapper to help decode 'Any' types within Codable structures +// (Keep AnyCodable as defined previously, it holds the necessary decoding logic) +struct AnyCodable: Decodable { + let value: Any? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intValue = try? container.decode(Int.self) { + value = intValue + } else if let doubleValue = try? container.decode(Double.self) { + value = doubleValue + } else if let stringValue = try? container.decode(String.self) { + value = stringValue + } else if let boolValue = try? container.decode(Bool.self) { + value = boolValue + } else if let arrayValue = try? container.decode([AnyCodable].self) { + value = arrayValue.map { $0.value } + } else if let dictValue = try? container.decode([String: AnyCodable].self) { + value = dictValue.mapValues { $0.value } + } else if container.decodeNil() { + value = nil + } else { + let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unsupported type in AnyCodable.") + throw DecodingError.dataCorrupted(context) + } + } +} + + +// Represents the variant associated with a feature flag +public struct MixpanelFlagVariant: Decodable { + public let key: String // Corresponds to 'variant_key' from API + public let value: Any? // Corresponds to 'variant_value' from API + + enum CodingKeys: String, CodingKey { + case key = "variant_key" + case value = "variant_value" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + key = try container.decode(String.self, forKey: .key) + + // Directly decode the 'variant_value' using AnyCodable. + // If the key is missing, it throws. + // If the value is null, AnyCodable handles it. + // If the value is an unsupported type, AnyCodable throws. + let anyCodableValue = try container.decode(AnyCodable.self, forKey: .value) + value = anyCodableValue.value // Extract the underlying Any? value + } + + // Helper initializer with fallbacks, value defaults to key if nil + public init(key: String = "", value: Any? = nil) { + self.key = key + if let value = value { + self.value = value + } else { + self.value = key + } + } +} + +// Response structure for the /flags endpoint +struct FlagsResponse: Decodable { + let flags: [String: MixpanelFlagVariant]? // Dictionary where key is flag name +} + +// --- FeatureFlagDelegate Protocol --- +public protocol MixpanelFlagDelegate: AnyObject { + func getOptions() -> MixpanelOptions + func getDistinctId() -> String + func track(event: String?, properties: Properties?) +} + +/// A protocol defining the public interface for a feature flagging system. +public protocol MixpanelFlags { + + /// The delegate responsible for handling feature flag lifecycle events, + /// such as tracking. It is declared `weak` to prevent retain cycles. + var delegate: MixpanelFlagDelegate? { get set } + + // --- Public Methods --- + + /// Initiates the loading or refreshing of flags + func loadFlags() + + /// Synchronously checks if the flags have been successfully loaded + /// and are available for querying. + /// + /// - Returns: `true` if the flags are loaded and ready for use, `false` otherwise. + func areFlagsReady() -> Bool + + // --- Sync Flag Retrieval --- + + /// Synchronously retrieves the complete `MixpanelFlagVariant` for a given flag name. + /// If the feature flag is found and flags are ready, its variant is returned. + /// Otherwise, the provided `fallback` `MixpanelFlagVariant` is returned. + /// This method will also trigger any necessary tracking logic for the accessed flag. + /// + /// - Parameters: + /// - flagName: The unique identifier for the feature flag. + /// - fallback: The `MixpanelFlagVariant` to return if the specified flag is not found + /// or if the flags are not yet loaded. + /// - Returns: The `MixpanelFlagVariant` associated with `flagName`, or the `fallback` variant. + func getVariantSync(_ flagName: String, fallback: MixpanelFlagVariant) -> MixpanelFlagVariant + + /// Asynchronously retrieves the complete `MixpanelFlagVariant` for a given flag name. + /// If flags are not ready, an attempt will be made to load them. + /// The `completion` handler is called with the `MixpanelFlagVariant` for the flag, + /// or the `fallback` variant if the flag is not found or loading fails. + /// This method will also trigger any necessary tracking logic for the accessed flag. + /// The completion handler is typically invoked on the main thread. + /// + /// - Parameters: + /// - flagName: The unique identifier for the feature flag. + /// - fallback: The `MixpanelFlagVariant` to use as a default if the specified flag + /// is not found or an error occurs during fetching. + /// - completion: A closure that is called with the resulting `MixpanelFlagVariant`. + /// This closure will be executed on the main dispatch queue. + func getVariant(_ flagName: String, fallback: MixpanelFlagVariant, completion: @escaping (MixpanelFlagVariant) -> Void) + + /// Synchronously retrieves the underlying value of a feature flag. + /// This is a convenience method that extracts the `value` property from the `MixpanelFlagVariant` + /// obtained via `getVariantSync`. + /// + /// - Parameters: + /// - flagName: The unique identifier for the feature flag. + /// - fallbackValue: The default value to return if the flag is not found, + /// its variant doesn't contain a value, or flags are not ready. + /// - Returns: The value of the feature flag, or `fallbackValue`. The type is `Any?`. + func getVariantValueSync(_ flagName: String, fallbackValue: Any?) -> Any? + + /// Asynchronously retrieves the underlying value of a feature flag. + /// This is a convenience method that extracts the `value` property from the `MixpanelFlagVariant` + /// obtained via `getVariant`. If flags are not ready, an attempt will be made to load them. + /// The `completion` handler is called with the flag's value or the `fallbackValue`. + /// The completion handler is typically invoked on the main thread. + /// + /// - Parameters: + /// - flagName: The unique identifier for the feature flag. + /// - fallbackValue: The default value to use if the flag is not found, + /// fetching fails, or its variant doesn't contain a value. + /// - completion: A closure that is called with the resulting value (`Any?`). + /// This closure will be executed on the main dispatch queue. + func getVariantValue(_ flagName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) + + /// Synchronously checks if a specific feature flag is considered "enabled". + /// This typically involves retrieving the flag's value and evaluating it as a boolean. + /// The exact logic for what constitutes "enabled" (e.g., `true`, non-nil, a specific string) + /// should be defined by the implementing class. + /// + /// - Parameters: + /// - flagName: The unique identifier for the feature flag. + /// - fallbackValue: The boolean value to return if the flag is not found, + /// cannot be evaluated as a boolean, or flags are not ready. Defaults to `false`. + /// - Returns: `true` if the flag is considered enabled, `false` otherwise (including if `fallbackValue` is used). + func isEnabledSync(_ flagName: String, fallbackValue: Bool) -> Bool + + /// Asynchronously checks if a specific feature flag is considered "enabled". + /// This typically involves retrieving the flag's value and evaluating it as a boolean. + /// If flags are not ready, an attempt will be made to load them. + /// The `completion` handler is called with the boolean result. + /// The completion handler is typically invoked on the main thread. + /// + /// - Parameters: + /// - flagName: The unique identifier for the feature flag. + /// - fallbackValue: The boolean value to use if the flag is not found, fetching fails, + /// or it cannot be evaluated as a boolean. Defaults to `false`. + /// - completion: A closure that is called with the boolean result. + /// This closure will be executed on the main dispatch queue. + func isEnabled(_ flagName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void) +} + + +// --- FeatureFlagManager Class --- + +class FeatureFlagManager: Network, MixpanelFlags { + + weak var delegate: MixpanelFlagDelegate? + + // *** Use a SERIAL queue for automatic state serialization *** + let accessQueue = DispatchQueue(label: "com.mixpanel.featureflagmanager.serialqueue") + + // Internal State - Protected by accessQueue + var flags: [String: MixpanelFlagVariant]? = nil + var isFetching: Bool = false + private var trackedFeatures: Set = Set() + private var fetchCompletionHandlers: [(Bool) -> Void] = [] + + // Configuration + private var currentOptions: MixpanelOptions? { delegate?.getOptions() } + private var flagsRoute = "/flags/" + + // Initializers + required init(serverURL: String) { + super.init(serverURL: serverURL) + } + + public init(serverURL: String, delegate: MixpanelFlagDelegate?) { + self.delegate = delegate + super.init(serverURL: serverURL) + } + + // --- Public Methods --- + + func loadFlags() { + // Dispatch fetch trigger to allow caller to continue + // Using the serial queue itself for this background task is fine + accessQueue.async { [weak self] in + self?._fetchFlagsIfNeeded(completion: nil) + } + } + + func areFlagsReady() -> Bool { + // Simple sync read - serial queue ensures this is safe + accessQueue.sync { flags != nil } + } + + // --- Sync Flag Retrieval --- + + func getVariantSync(_ flagName: String, fallback: MixpanelFlagVariant) -> MixpanelFlagVariant { + var flagVariant: MixpanelFlagVariant? + var tracked = false + // === Serial Queue: Single Sync Block for Read AND Track Update === + accessQueue.sync { + guard let currentFlags = self.flags else { return } + + if let variant = currentFlags[flagName] { + flagVariant = variant + + // Perform atomic check-and-set for tracking *within the same sync block* + if !self.trackedFeatures.contains(flagName) { + self.trackedFeatures.insert(flagName) + tracked = true + } + } + // If flag wasn't found, flagVariant remains nil + } + // === End Sync Block === + + // Now, process the results outside the lock + + if let foundVariant = flagVariant { + // If tracking was done *in this call*, call the delegate + if tracked { + self._performTrackingDelegateCall(flagName: flagName, variant: foundVariant) + } + return foundVariant + } else { + print("Info: Flag '\(flagName)' not found or flags not ready. Returning fallback.") + return fallback + } + } + + // --- Async Flag Retrieval --- + + func getVariant(_ flagName: String, fallback: MixpanelFlagVariant, completion: @escaping (MixpanelFlagVariant) -> Void) { + accessQueue.async { [weak self] in // Block A runs serially on accessQueue + guard let self = self else { return } + + var flagVariant: MixpanelFlagVariant? + var needsTrackingCheck = false + var flagsAreCurrentlyReady = false + + // === Access state DIRECTLY within the async block === + // No inner sync needed - we are already synchronized by the serial queue + flagsAreCurrentlyReady = (self.flags != nil) + if flagsAreCurrentlyReady, let currentFlags = self.flags { + if let variant = currentFlags[flagName] { + flagVariant = variant + // Also safe to access trackedFeatures directly here + needsTrackingCheck = !self.trackedFeatures.contains(flagName) + } + } + // === State access finished === + + if flagsAreCurrentlyReady { + let result = flagVariant ?? fallback + if flagVariant != nil, needsTrackingCheck { + // Perform atomic check-and-track. _trackFeatureIfNeeded uses its + // own sync block, which is safe to call from here (it's not nested). + self._trackFlagIfNeeded(flagName: flagName, variant: result) + } + DispatchQueue.main.async { completion(result) } + + } else { + // --- Flags were NOT ready --- + // Trigger fetch; fetch completion will handle calling the original completion handler + print("Flags not ready, attempting fetch for getFeature call...") + self._fetchFlagsIfNeeded { success in + // This completion runs *after* fetch completes (or fails) + let result: MixpanelFlagVariant + if success { + // Fetch succeeded, get the flag SYNCHRONOUSLY + result = self.getVariantSync(flagName, fallback: fallback) + } else { + print("Warning: Failed to fetch flags, returning fallback for \(flagName).") + result = fallback + } + // Call original completion (on main thread) + DispatchQueue.main.async { completion(result) } + } + + return // Exit Block A early, fetch completion handles the callback. + + } + } // End accessQueue.async (Block A) + } + + func getVariantValueSync(_ flagName: String, fallbackValue: Any?) -> Any? { + return getVariantSync(flagName, fallback: MixpanelFlagVariant(value: fallbackValue)).value + } + + func getVariantValue(_ flagName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) { + getVariant(flagName, fallback: MixpanelFlagVariant(value: fallbackValue)) { flagVariant in + completion(flagVariant.value) + } + } + + func isEnabledSync(_ flagName: String, fallbackValue: Bool = false) -> Bool { + let variantValue = getVariantValueSync(flagName, fallbackValue: fallbackValue) + return self._evaluateBooleanFlag(flagName: flagName, variantValue: variantValue, fallbackValue: fallbackValue) + } + + func isEnabled(_ flagName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { + getVariantValue(flagName, fallbackValue: fallbackValue) { [weak self] variantValue in + guard let self = self else { + completion(fallbackValue) + return + } + let result = self._evaluateBooleanFlag(flagName: flagName, variantValue: variantValue, fallbackValue: fallbackValue) + completion(result) + } + } + + // --- Fetching Logic (Simplified by Serial Queue) --- + + // Internal function to handle fetch logic and state checks + private func _fetchFlagsIfNeeded(completion: ((Bool) -> Void)?) { + + var shouldStartFetch = false + let optionsSnapshot = self.currentOptions // Read options directly (safe on accessQueue) + + + guard let options = optionsSnapshot, options.featureFlagsEnabled else { + print("Feature flags are disabled, not fetching.") + // Call completion immediately since we know the result and are on the queue. + completion?(false) + return // Exit method + } + + // Access/Modify isFetching and fetchCompletionHandlers directly (safe on accessQueue) + if !self.isFetching { + self.isFetching = true + shouldStartFetch = true + if let completion = completion { + self.fetchCompletionHandlers.append(completion) + } + } else { + print("Fetch already in progress, queueing completion handler.") + if let completion = completion { + self.fetchCompletionHandlers.append(completion) + } + } + // State modifications related to starting the fetch are complete + + if shouldStartFetch { + print("Starting flag fetch (dispatching network request)...") + // Perform network request OUTSIDE the serial accessQueue context + // to avoid blocking the queue during network latency. + // Dispatch the network request initiation to a global queue. + DispatchQueue.global(qos: .utility).async { [weak self] in + self?._performFetchRequest() + } + } + } + + + // Performs the actual network request construction and call + private func _performFetchRequest() { + // This method runs OUTSIDE the accessQueue + + guard let delegate = self.delegate, let options = self.currentOptions else { + print("Error: Delegate or options missing for fetch.") + self._completeFetch(success: false) + return + } + + let distinctId = delegate.getDistinctId() + print("Fetching flags for distinct ID: \(distinctId)") + + var context = options.featureFlagsContext + context["distinct_id"] = distinctId + let requestBodyDict = ["context": context] + + guard let requestBodyData = try? JSONSerialization.data(withJSONObject: requestBodyDict, options: []) else { + print("Error: Failed to serialize request body for flags.") + self._completeFetch(success: false); return + } + guard let authData = "\(options.token):".data(using: .utf8) else { + print("Error: Failed to create auth data."); self._completeFetch(success: false); return + } + let base64Auth = authData.base64EncodedString() + let headers = ["Authorization": "Basic \(base64Auth)", "Content-Type": "application/json"] + let responseParser: (Data) -> FlagsResponse? = { data in + do { return try JSONDecoder().decode(FlagsResponse.self, from: data) } + catch { print("Error parsing flags JSON: \(error)"); return nil } + } + let resource = Network.buildResource(path: flagsRoute, method: .post, requestBody: requestBodyData, headers: headers, parse: responseParser) + + // Make the API request + Network.apiRequest( + base: serverURL, + resource: resource, + failure: { [weak self] reason, data, response in // Completion handlers run on URLSession's queue + print("Error: Failed to fetch flags. Reason: \(reason)") + // Update state and call completions via _completeFetch on the serial queue + self?.accessQueue.async { // Dispatch completion handling to serial queue + self?._completeFetch(success: false) + } + }, + success: { [weak self] (flagsResponse, response) in // Completion handlers run on URLSession's queue + print("Successfully fetched flags.") + guard let self = self else { return } + // Update state and call completions via _completeFetch on the serial queue + self.accessQueue.async { [weak self] in + guard let self = self else { return } + // already on accessQueue – write directly + self.flags = flagsResponse.flags ?? [:] + print("Flags updated: \(self.flags ?? [:])") + self._completeFetch(success: true) // still on accessQueue + } + } + ) + } + + // Centralized fetch completion logic - MUST be called from within accessQueue + func _completeFetch(success: Bool) { + self.isFetching = false + let handlers = self.fetchCompletionHandlers + self.fetchCompletionHandlers.removeAll() + + DispatchQueue.main.async { + handlers.forEach { $0(success) } + } + } + + + // --- Tracking Logic --- + + // Performs the atomic check and triggers delegate call if needed + private func _trackFlagIfNeeded(flagName: String, variant: MixpanelFlagVariant) { + var shouldCallDelegate = false + + // We are already executing on the serial accessQueue, so this is safe. + if !self.trackedFeatures.contains(flagName) { + self.trackedFeatures.insert(flagName) + shouldCallDelegate = true + } + + // Call delegate *outside* this conceptual block if tracking occurred + // This prevents holding any potential implicit lock during delegate execution + if shouldCallDelegate { + self._performTrackingDelegateCall(flagName: flagName, variant: variant) + } + } + + // Helper to just call the delegate (no locking) + private func _performTrackingDelegateCall(flagName: String, variant: MixpanelFlagVariant) { + guard let delegate = self.delegate else { return } + let properties: Properties = [ + "Experiment name": flagName, "Variant name": variant.key, "$experiment_type": "feature_flag" + ] + // Dispatch delegate call asynchronously to main thread for safety + DispatchQueue.main.async { + delegate.track(event: "$experiment_started", properties: properties) + print("Tracked $experiment_started for \(flagName) (dispatched to main)") + } + } + + // --- Boolean Evaluation Helper --- + private func _evaluateBooleanFlag(flagName: String, variantValue: Any?, fallbackValue: Bool) -> Bool { + guard let val = variantValue else { return fallbackValue } + if let boolVal = val as? Bool { return boolVal } + else { print("Error: Flag '\(flagName)' is not Bool"); return fallbackValue } + } +} diff --git a/Sources/Mixpanel.swift b/Sources/Mixpanel.swift index 0666bb8a..ba22af39 100644 --- a/Sources/Mixpanel.swift +++ b/Sources/Mixpanel.swift @@ -14,6 +14,11 @@ import UIKit /// The primary class for integrating Mixpanel with your app. open class Mixpanel { + @discardableResult + open class func initialize(options: MixpanelOptions) -> MixpanelInstance { + return MixpanelManager.sharedInstance.initialize(options: options) + } + #if !os(OSX) && !os(watchOS) /** Initializes an instance of the API with the given project token. @@ -276,6 +281,13 @@ final class MixpanelManager { instanceQueue = DispatchQueue(label: "com.mixpanel.instance.manager.instance", qos: .utility, autoreleaseFrequency: .workItem) } + func initialize(options: MixpanelOptions) -> MixpanelInstance { + let instanceName = options.instanceName ?? options.token + return dequeueInstance(instanceName: instanceName) { + return MixpanelInstance(options: options) + } + } + func initialize(token apiToken: String, flushInterval: Double, instanceName: String, @@ -383,5 +395,6 @@ final class MixpanelManager { } } + } diff --git a/Sources/MixpanelInstance.swift b/Sources/MixpanelInstance.swift index ea5f5568..b8854ccb 100644 --- a/Sources/MixpanelInstance.swift +++ b/Sources/MixpanelInstance.swift @@ -75,7 +75,9 @@ public struct ProxyServerConfig { } /// The class that represents the Mixpanel Instance -open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDelegate { +open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDelegate, MixpanelFlagDelegate { + + private let options: MixpanelOptions /// apiToken string that identifies the project to track data to open var apiToken = "" @@ -102,6 +104,9 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele /// Accessor to the Mixpanel People API object. open var people: People! + /// Accessor the Mixpanel Feature Flags API object. + open var flags: MixpanelFlags! + let mixpanelPersistence: MixpanelPersistence /// Accessor to the Mixpanel People API object. @@ -268,6 +273,20 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele private let registerSuperPropertiesNotificationName = Notification.Name("com.mixpanel.properties.register") private let unregisterSuperPropertiesNotificationName = Notification.Name("com.mixpanel.properties.unregister") + convenience init(options: MixpanelOptions) { + self.init(apiToken: options.token, + flushInterval: options.flushInterval, + name: options.instanceName ?? options.token, + trackAutomaticEvents: options.trackAutomaticEvents, + optOutTrackingByDefault: options.optOutTrackingByDefault, + useUniqueDistinctId: options.useUniqueDistinctId, + superProperties: options.superProperties, + serverURL: options.serverURL, + proxyServerDelegate: options.proxyServerConfig?.delegate, + useGzipCompression: options.useGzipCompression, + options: options) + } + convenience init( apiToken: String?, flushInterval: Double, @@ -325,8 +344,22 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele superProperties: Properties? = nil, serverURL: String? = nil, proxyServerDelegate: MixpanelProxyServerDelegate? = nil, - useGzipCompression: Bool = false + useGzipCompression: Bool = false, + options: MixpanelOptions? = nil ) { + // Store the config if provided, otherwise create one with the current values + self.options = options ?? MixpanelOptions( + token: apiToken ?? "", + flushInterval: flushInterval, + instanceName: name, + trackAutomaticEvents: trackAutomaticEvents, + optOutTrackingByDefault: optOutTrackingByDefault, + useUniqueDistinctId: useUniqueDistinctId, + superProperties: superProperties, + serverURL: serverURL, + useGzipCompression: useGzipCompression + ) + if let apiToken = apiToken, !apiToken.isEmpty { self.apiToken = apiToken } @@ -352,7 +385,9 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele instanceName: self.name, lock: self.readWriteLock, metadata: sessionMetadata, mixpanelPersistence: mixpanelPersistence) + flags = FeatureFlagManager(serverURL: self.serverURL) trackInstance.mixpanelInstance = self + flags.delegate = self #if os(iOS) && !targetEnvironment(macCatalyst) if let reachability = MixpanelInstance.reachability { var context = SCNetworkReachabilityContext(version: 0, info: nil, retain: nil, release: nil, copyDescription: nil) @@ -404,6 +439,15 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele automaticEvents.initializeEvents(instanceName: self.name) } #endif + flags.loadFlags() + } + + public func getOptions() -> MixpanelOptions { + return options + } + + public func getDistinctId() -> String { + return distinctId } #if !os(OSX) && !os(watchOS) @@ -745,6 +789,7 @@ extension MixpanelInstance { self.distinctId = distinctId self.userId = distinctId } + self.flags.loadFlags() self.track(event: "$identify", properties: ["$anon_distinct_id": oldDistinctId]) } diff --git a/Sources/MixpanelOptions.swift b/Sources/MixpanelOptions.swift new file mode 100644 index 00000000..cbd30c06 --- /dev/null +++ b/Sources/MixpanelOptions.swift @@ -0,0 +1,49 @@ +// +// MixpanelOptions.swift +// Mixpanel +// +// Created by Jared McFarland on 4/15/25. +// Copyright © 2025 Mixpanel. All rights reserved. +// + + +public class MixpanelOptions { + public let token: String + public let flushInterval: Double + public let instanceName: String? + public let trackAutomaticEvents: Bool + public let optOutTrackingByDefault: Bool + public let useUniqueDistinctId: Bool + public let superProperties: Properties? + public let serverURL: String? + public let proxyServerConfig: ProxyServerConfig? + public let useGzipCompression: Bool + public let featureFlagsEnabled: Bool + public let featureFlagsContext: [String: Any] + + public init(token: String, + flushInterval: Double = 60, + instanceName: String? = nil, + trackAutomaticEvents: Bool = false, + optOutTrackingByDefault: Bool = false, + useUniqueDistinctId: Bool = false, + superProperties: Properties? = nil, + serverURL: String? = nil, + proxyServerConfig: ProxyServerConfig? = nil, + useGzipCompression: Bool = true, // NOTE: This is a new default value! + featureFlagsEnabled: Bool = false, + featureFlagsContext: [String: Any] = [:]) { + self.token = token + self.flushInterval = flushInterval + self.instanceName = instanceName + self.trackAutomaticEvents = trackAutomaticEvents + self.optOutTrackingByDefault = optOutTrackingByDefault + self.useUniqueDistinctId = useUniqueDistinctId + self.superProperties = superProperties + self.serverURL = serverURL + self.proxyServerConfig = proxyServerConfig + self.useGzipCompression = useGzipCompression + self.featureFlagsEnabled = featureFlagsEnabled + self.featureFlagsContext = featureFlagsContext + } +} diff --git a/Sources/MixpanelPersistence.swift b/Sources/MixpanelPersistence.swift index 24f72dc2..ba49ce51 100644 --- a/Sources/MixpanelPersistence.swift +++ b/Sources/MixpanelPersistence.swift @@ -47,6 +47,7 @@ struct MixpanelUserDefaultsKeys { static let userID = "MPUserId" static let alias = "MPAlias" static let hadPersistedDistinctId = "MPHadPersistedDistinctId" + static let flags = "MPFlags" } class MixpanelPersistence { @@ -189,6 +190,39 @@ class MixpanelPersistence { } } + /// -- Feature Flags -- + /// NOT currently used + + static func saveFlags(flags: InternalProperties, instanceName: String) { + guard let defaults = UserDefaults(suiteName: MixpanelUserDefaultsKeys.suiteName) else { + return + } + let prefix = "\(MixpanelUserDefaultsKeys.prefix)-\(instanceName)-" + do { + let flagsData = try NSKeyedArchiver.archivedData(withRootObject: flags, requiringSecureCoding: false) + defaults.set(flagsData, forKey: "\(prefix)\(MixpanelUserDefaultsKeys.flags)") + defaults.synchronize() + } catch { + MixpanelLogger.warn(message: "Failed to archive flags") + } + } + + static func loadFlags(instanceName: String) -> InternalProperties { + guard let defaults = UserDefaults(suiteName: MixpanelUserDefaultsKeys.suiteName) else { + return InternalProperties() + } + let prefix = "\(MixpanelUserDefaultsKeys.prefix)-\(instanceName)-" + guard let flags = defaults.data(forKey: "\(prefix)\(MixpanelUserDefaultsKeys.flags)") else { + return InternalProperties() + } + do { + return try NSKeyedUnarchiver.unarchivedObject(ofClasses: archivedClasses, from: flags) as? InternalProperties ?? InternalProperties() + } catch { + MixpanelLogger.warn(message: "Failed to unarchive flags") + return InternalProperties() + } + } + static func saveIdentity(_ mixpanelIdentity: MixpanelIdentity, instanceName: String) { guard let defaults = UserDefaults(suiteName: MixpanelUserDefaultsKeys.suiteName) else { return