diff --git a/Sources/Segment/Analytics.swift b/Sources/Segment/Analytics.swift index 5334f9ca..797088ec 100644 --- a/Sources/Segment/Analytics.swift +++ b/Sources/Segment/Analytics.swift @@ -78,7 +78,7 @@ public class Analytics { // provide our default state store.provide(state: System.defaultState(configuration: configuration, from: storage)) - store.provide(state: UserInfo.defaultState(from: storage)) + store.provide(state: UserInfo.defaultState(from: storage, anonIdGenerator: configuration.values.anonymousIdGenerator)) storage.analytics = self diff --git a/Sources/Segment/Configuration.swift b/Sources/Segment/Configuration.swift index edc3d0f1..15b9a004 100644 --- a/Sources/Segment/Configuration.swift +++ b/Sources/Segment/Configuration.swift @@ -11,6 +11,16 @@ import JSONSafeEncoder import FoundationNetworking #endif +// MARK: - Custom AnonymousId generator +/// Conform to this protocol to generate your own AnonymousID +public protocol AnonymousIdGenerator: AnyObject, Codable { + /// Returns a new anonymousId. Segment still manages storage and retrieval of the + /// current anonymousId and will call this method when new id's are needed. + /// + /// - Returns: A new anonymousId. + func newAnonymousId() -> String +} + // MARK: - Operating Mode /// Specifies the operating mode/context public enum OperatingMode { @@ -56,6 +66,7 @@ public class Configuration { var userAgent: String? = nil var jsonNonConformingNumberStrategy: JSONSafeEncoder.NonConformingFloatEncodingStrategy = .zero var storageMode: StorageMode = .disk + var anonymousIdGenerator: AnonymousIdGenerator = SegmentAnonymousId() } internal var values: Values @@ -248,11 +259,19 @@ public extension Configuration { return self } + /// Specify the storage mode to use. The default is `.disk`. @discardableResult func storageMode(_ mode: StorageMode) -> Configuration { values.storageMode = mode return self } + + /// Specify a custom anonymousId generator. The default is and instance of `SegmentAnonymousId`. + @discardableResult + func anonymousIdGenerator(_ generator: AnonymousIdGenerator) -> Configuration { + values.anonymousIdGenerator = generator + return self + } } extension Analytics { diff --git a/Sources/Segment/Plugins/SegmentDestination.swift b/Sources/Segment/Plugins/SegmentDestination.swift index 91adce24..5b6d846c 100644 --- a/Sources/Segment/Plugins/SegmentDestination.swift +++ b/Sources/Segment/Plugins/SegmentDestination.swift @@ -16,6 +16,12 @@ import Sovran import FoundationNetworking #endif +public class SegmentAnonymousId: AnonymousIdGenerator { + public func newAnonymousId() -> String { + return UUID().uuidString + } +} + public class SegmentDestination: DestinationPlugin, Subscriber, FlushCompletion { internal enum Constants: String { case integrationName = "Segment.io" diff --git a/Sources/Segment/State.swift b/Sources/Segment/State.swift index ede47290..0c8e798a 100644 --- a/Sources/Segment/State.swift +++ b/Sources/Segment/State.swift @@ -111,9 +111,17 @@ struct UserInfo: Codable, State { let traits: JSON? let referrer: URL? + @Noncodable var anonIdGenerator: AnonymousIdGenerator? + struct ResetAction: Action { func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: UUID().uuidString, userId: nil, traits: nil, referrer: nil) + var anonId: String + if let id = state.anonIdGenerator?.newAnonymousId() { + anonId = id + } else { + anonId = UUID().uuidString + } + return UserInfo(anonymousId: anonId, userId: nil, traits: nil, referrer: nil, anonIdGenerator: state.anonIdGenerator) } } @@ -121,7 +129,7 @@ struct UserInfo: Codable, State { let userId: String func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: state.traits, referrer: state.referrer) + return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: state.traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator) } } @@ -129,7 +137,7 @@ struct UserInfo: Codable, State { let traits: JSON? func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: traits, referrer: state.referrer) + return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator) } } @@ -138,15 +146,7 @@ struct UserInfo: Codable, State { let traits: JSON? func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: traits, referrer: state.referrer) - } - } - - struct SetAnonymousIdAction: Action { - let anonymousId: String - - func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: anonymousId, userId: state.userId, traits: state.traits, referrer: state.referrer) + return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator) } } @@ -154,7 +154,7 @@ struct UserInfo: Codable, State { let url: URL func reduce(state: UserInfo) -> UserInfo { - return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: state.traits, referrer: url) + return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: state.traits, referrer: url, anonIdGenerator: state.anonIdGenerator) } } } @@ -176,13 +176,15 @@ extension System { } extension UserInfo { - static func defaultState(from storage: Storage) -> UserInfo { + static func defaultState(from storage: Storage, anonIdGenerator: AnonymousIdGenerator) -> UserInfo { let userId: String? = storage.read(.userId) let traits: JSON? = storage.read(.traits) - var anonymousId: String = UUID().uuidString + var anonymousId: String if let existingId: String = storage.read(.anonymousId) { anonymousId = existingId + } else { + anonymousId = anonIdGenerator.newAnonymousId() } - return UserInfo(anonymousId: anonymousId, userId: userId, traits: traits, referrer: nil) + return UserInfo(anonymousId: anonymousId, userId: userId, traits: traits, referrer: nil, anonIdGenerator: anonIdGenerator) } } diff --git a/Sources/Segment/Utilities/Noncodable.swift b/Sources/Segment/Utilities/Noncodable.swift new file mode 100644 index 00000000..e986c63a --- /dev/null +++ b/Sources/Segment/Utilities/Noncodable.swift @@ -0,0 +1,34 @@ +// +// Noncodable.swift +// +// +// Created by Brandon Sneed on 4/17/24. +// + +import Foundation + +@propertyWrapper +internal struct Noncodable: Codable { + public var wrappedValue: T? + public init(wrappedValue: T?) { + self.wrappedValue = wrappedValue + } + public init(from decoder: Decoder) throws { + self.wrappedValue = nil + } + public func encode(to encoder: Encoder) throws { + // Do nothing + } +} + +extension KeyedDecodingContainer { + internal func decode(_ type: Noncodable.Type, forKey key: Self.Key) throws -> Noncodable { + return Noncodable(wrappedValue: nil) + } +} + +extension KeyedEncodingContainer { + internal mutating func encode(_ value: Noncodable, forKey key: KeyedEncodingContainer.Key) throws { + // Do nothing + } +} diff --git a/Sources/Segment/Utilities/Utils.swift b/Sources/Segment/Utilities/Utils.swift index b06d2695..6e11385a 100644 --- a/Sources/Segment/Utilities/Utils.swift +++ b/Sources/Segment/Utilities/Utils.swift @@ -84,3 +84,5 @@ internal func eventStorageDirectory(writeKey: String) -> URL { try? FileManager.default.createDirectory(at: segmentURL, withIntermediateDirectories: true, attributes: nil) return segmentURL } + + diff --git a/Tests/Segment-Tests/Analytics_Tests.swift b/Tests/Segment-Tests/Analytics_Tests.swift index 83c35687..b7371bff 100644 --- a/Tests/Segment-Tests/Analytics_Tests.swift +++ b/Tests/Segment-Tests/Analytics_Tests.swift @@ -142,10 +142,11 @@ final class Analytics_Tests: XCTestCase { let expectation = XCTestExpectation(description: "MyDestination Expectation") let myDestination = MyDestination(disabled: true) { expectation.fulfill() + print("called") return true } - let configuration = Configuration(writeKey: "test") + let configuration = Configuration(writeKey: "testDestNotEnabled") let analytics = Analytics(configuration: configuration) analytics.add(plugin: myDestination) @@ -754,25 +755,36 @@ final class Analytics_Tests: XCTestCase { .flushAt(9999) .operatingMode(.asynchronous)) + // set the httpclient to use our blocker session + let segment = analytics.find(pluginType: SegmentDestination.self) + let configuration = URLSessionConfiguration.ephemeral + configuration.allowsCellularAccess = true + configuration.timeoutIntervalForResource = 30 + configuration.timeoutIntervalForRequest = 60 + configuration.httpMaximumConnectionsPerHost = 2 + configuration.protocolClasses = [BlockNetworkCalls.self] + configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charset=utf-8", + "Authorization": "Basic test", + "User-Agent": "analytics-ios/\(Analytics.version())"] + let blockSession = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil) + segment?.httpClient?.session = blockSession + waitUntilStarted(analytics: analytics) analytics.storage.hardReset(doYouKnowHowToUseThis: true) - @Atomic var completionCalled = false + let expectation = XCTestExpectation() // put an event in the pipe ... analytics.track(name: "completion test1") // flush it, that'll get us an upload going analytics.flush { // verify completion is called. - completionCalled = true + expectation.fulfill() } - while !completionCalled { - RunLoop.main.run(until: Date.distantPast) - } + wait(for: [expectation], timeout: 5) - XCTAssertTrue(completionCalled) XCTAssertNil(analytics.pendingUploads) } @@ -783,22 +795,35 @@ final class Analytics_Tests: XCTestCase { .flushAt(9999) .operatingMode(.synchronous)) + // set the httpclient to use our blocker session + let segment = analytics.find(pluginType: SegmentDestination.self) + let configuration = URLSessionConfiguration.ephemeral + configuration.allowsCellularAccess = true + configuration.timeoutIntervalForResource = 30 + configuration.timeoutIntervalForRequest = 60 + configuration.httpMaximumConnectionsPerHost = 2 + configuration.protocolClasses = [BlockNetworkCalls.self] + configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charset=utf-8", + "Authorization": "Basic test", + "User-Agent": "analytics-ios/\(Analytics.version())"] + let blockSession = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil) + segment?.httpClient?.session = blockSession + waitUntilStarted(analytics: analytics) analytics.storage.hardReset(doYouKnowHowToUseThis: true) - @Atomic var completionCalled = false - + let expectation = XCTestExpectation() // put an event in the pipe ... analytics.track(name: "completion test1") // flush it, that'll get us an upload going analytics.flush { // verify completion is called. - completionCalled = true + expectation.fulfill() } - // completion shouldn't be called before flush returned. - XCTAssertTrue(completionCalled) + wait(for: [expectation], timeout: 1) + XCTAssertNil(analytics.pendingUploads) // put another event in the pipe. @@ -921,4 +946,74 @@ final class Analytics_Tests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) } #endif + + func testAnonIDGenerator() throws { + class MyAnonIdGenerator: AnonymousIdGenerator { + var currentId: String = "blah-" + func newAnonymousId() -> String { + currentId = currentId + "1" + return currentId + } + } + + // need to clear settings for this one. + UserDefaults.standard.removePersistentDomain(forName: "com.segment.storage.anonIdGenerator") + + let anonIdGenerator = MyAnonIdGenerator() + var analytics: Analytics? = Analytics(configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator(anonIdGenerator)) + let outputReader = OutputReaderPlugin() + analytics?.add(plugin: outputReader) + + waitUntilStarted(analytics: analytics) + XCTAssertEqual(analytics?.anonymousId, "blah-1") + + analytics?.track(name: "Test1") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-1") + XCTAssertEqual(anonIdGenerator.currentId, "blah-1") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + analytics?.track(name: "Test2") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-1") + XCTAssertEqual(anonIdGenerator.currentId, "blah-1") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + analytics?.reset() + + analytics?.track(name: "Test3") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-11") + XCTAssertEqual(anonIdGenerator.currentId, "blah-11") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + analytics?.identify(userId: "Roger") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-11") + XCTAssertEqual(anonIdGenerator.currentId, "blah-11") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + analytics?.reset() + + analytics?.screen(title: "Screen") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-111") + XCTAssertEqual(anonIdGenerator.currentId, "blah-111") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + // get rid of this instance, leave it time to go away ... + // ... also let any state updates happen as handlers get called async + RunLoop.main.run(until: .distantPast) + analytics = nil + // ... give it some time to release all it's stuff. + RunLoop.main.run(until: .distantPast) + + // make sure it makes it to the next instance + analytics = Analytics(configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator(anonIdGenerator)) + analytics?.add(plugin: outputReader) + + waitUntilStarted(analytics: analytics) + + // same anonId as last time, yes? + analytics?.screen(title: "Screen") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-111") + XCTAssertEqual(anonIdGenerator.currentId, "blah-111") + XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId) + + } }