From 147288af30d77d760ad3024a0d2e622e1873bedc Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 2 Apr 2026 15:24:53 -0400 Subject: [PATCH 01/31] Be consistent about logging --- Sources/MinFraudDevice/Collector/DeviceDataCollector.swift | 4 +++- Sources/MinFraudDevice/DeviceTracker.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift b/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift index b827143..ab5310a 100644 --- a/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift +++ b/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift @@ -39,7 +39,9 @@ final class DeviceDataCollector: @unchecked Sendable { throw MinFraudDeviceError.idfvUnavailable } - if !storage.set(systemIDFV, forKey: KeychainStorage.idfvKey) { + if storage.set(systemIDFV, forKey: KeychainStorage.idfvKey) { + logger?.debug("Cached IDFV in keychain") + } else { logger?.warning("Failed to cache IDFV in keychain") } diff --git a/Sources/MinFraudDevice/DeviceTracker.swift b/Sources/MinFraudDevice/DeviceTracker.swift index 7492623..480dcbb 100644 --- a/Sources/MinFraudDevice/DeviceTracker.swift +++ b/Sources/MinFraudDevice/DeviceTracker.swift @@ -99,7 +99,9 @@ public final class DeviceTracker: @unchecked Sendable { } if storage.set(token, forKey: KeychainStorage.trackingTokenKey) { - logger?.debug("Tracking token saved from server response") + logger?.debug("Cached tracking token from server response in keychain") + } else { + logger?.warning("Failed to cache tracking token in keychain") } return TrackingResult(trackingToken: token) From ebe114ab5a3773285fc847691ee0abf88cb756ab Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 2 Apr 2026 15:34:59 -0400 Subject: [PATCH 02/31] Lock is not required as nothing else could affect the auto task --- Sources/MinFraudDevice/DeviceTracker.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/MinFraudDevice/DeviceTracker.swift b/Sources/MinFraudDevice/DeviceTracker.swift index 480dcbb..ee0e8fa 100644 --- a/Sources/MinFraudDevice/DeviceTracker.swift +++ b/Sources/MinFraudDevice/DeviceTracker.swift @@ -142,8 +142,6 @@ public final class DeviceTracker: @unchecked Sendable { } deinit { - lock.lock() - defer { lock.unlock() } automaticCollectionTask?.cancel() } } From 7d5b04adaea4a74f63f69a3209e877d4503ffb9b Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 2 Apr 2026 15:46:16 -0400 Subject: [PATCH 03/31] Be explicit about sendability of struct --- Sources/MinFraudDevice/Model/DeviceData.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MinFraudDevice/Model/DeviceData.swift b/Sources/MinFraudDevice/Model/DeviceData.swift index ac4625c..105642c 100644 --- a/Sources/MinFraudDevice/Model/DeviceData.swift +++ b/Sources/MinFraudDevice/Model/DeviceData.swift @@ -1,6 +1,6 @@ import Foundation -struct DeviceData: Encodable { +struct DeviceData: Encodable, Sendable { let idfv: String let trackingToken: String? let requestDurationMS: Int? From d2b349a32eb3e0502a10bf2bf230f6637a6a1fcf Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 2 Apr 2026 21:19:15 -0400 Subject: [PATCH 04/31] Use more complete .whitespacesAndNewlines --- Sources/MinFraudDevice/DeviceTracker.swift | 2 +- Sources/MinFraudDevice/Model/TrackingResult.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/MinFraudDevice/DeviceTracker.swift b/Sources/MinFraudDevice/DeviceTracker.swift index ee0e8fa..a448633 100644 --- a/Sources/MinFraudDevice/DeviceTracker.swift +++ b/Sources/MinFraudDevice/DeviceTracker.swift @@ -94,7 +94,7 @@ public final class DeviceTracker: @unchecked Sendable { let response = try await apiClient.sendDeviceData(deviceData) guard let token = response.trackingToken, - !token.trimmingCharacters(in: .whitespaces).isEmpty else { + !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw MinFraudDeviceError.missingTrackingToken } diff --git a/Sources/MinFraudDevice/Model/TrackingResult.swift b/Sources/MinFraudDevice/Model/TrackingResult.swift index 1950c01..3d3540a 100644 --- a/Sources/MinFraudDevice/Model/TrackingResult.swift +++ b/Sources/MinFraudDevice/Model/TrackingResult.swift @@ -12,7 +12,7 @@ public struct TrackingResult: Sendable, CustomStringConvertible { public let trackingToken: String init(trackingToken: String) { - precondition(!trackingToken.trimmingCharacters(in: .whitespaces).isEmpty, "Tracking token must not be blank") + precondition(!trackingToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, "Tracking token must not be blank") self.trackingToken = trackingToken } From ea49557dcb7646585dd4adec99c924616a441526 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Fri, 3 Apr 2026 13:44:54 -0400 Subject: [PATCH 05/31] Stored ID internal and on the wire, Tracking Token for public interface --- CLAUDE.md | 10 +- .../Collector/DeviceDataCollector.swift | 4 +- Sources/MinFraudDevice/DeviceTracker.swift | 25 ++--- Sources/MinFraudDevice/Model/DeviceData.swift | 4 +- .../MinFraudDevice/Model/ServerResponse.swift | 42 ++++++- .../MinFraudDevice/Model/TrackingResult.swift | 5 +- .../Network/DeviceAPIClient.swift | 2 +- .../Storage/KeychainStorage.swift | 2 +- .../DeviceAPIClientTests.swift | 103 +++++++++--------- .../DeviceDataCollectorTests.swift | 10 +- .../DeviceTrackerTests.swift | 83 ++------------ Tests/MinFraudDeviceTests/ModelTests.swift | 81 ++++++++------ 12 files changed, 176 insertions(+), 195 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f22421b..47fd254 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Package Manager and targets iOS 15+. Follow Swift API Design Guidelines: - All-caps for acronyms: `SDKConfig`, `DeviceAPIClient`, `IDFV`, `URL` -- Use "tracking token" in public API, matching the minFraud API terminology +- Use "tracking token" in public API, "stored ID" internally and on the wire - Time units as suffixes: `collectionIntervalSeconds`, `requestDurationMS` ## Build Commands @@ -99,7 +99,7 @@ instances directly. 3. **Data Collection Layer** (`Collector/DeviceDataCollector.swift`) - Retrieves IDFV from Keychain (cached) or system (`UIDevice`) - - Reads tracking token from Keychain for inclusion in requests + - Reads stored ID from Keychain for inclusion in requests - Throws `MinFraudDeviceError.idfvUnavailable` if IDFV cannot be obtained 4. **Network Layer** (`Network/DeviceAPIClient.swift`) @@ -115,13 +115,13 @@ To capture both IP addresses for a device: 2. If response contains `ip_version: 6`, POST to `d-ipv4.mmapiws.com/device/ios` with request duration 3. IPv4 failure is non-fatal (logged, not propagated) -4. Tracking token from IPv6 response is returned and persisted +4. Stored ID from IPv6 response is persisted and returned as a tracking token If a custom server URL is configured, dual-request is disabled. ### Storage -`KeychainStorage` persists IDFV and tracking token in the iOS Keychain: +`KeychainStorage` persists IDFV and stored ID in the iOS Keychain: - Service: `com.maxmind.minfraud.device` - Accessibility: `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` @@ -145,5 +145,5 @@ disabled, `logger` is `nil` and all `logger?.method()` calls are no-ops. ## Error Types -- `MinFraudDeviceError` (public) — `idfvUnavailable`, `missingTrackingToken` +- `MinFraudDeviceError` (public) — `idfvUnavailable` - `APIError` (public) — `serverError(statusCode:message:)` diff --git a/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift b/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift index ab5310a..6383758 100644 --- a/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift +++ b/Sources/MinFraudDevice/Collector/DeviceDataCollector.swift @@ -21,11 +21,11 @@ final class DeviceDataCollector: @unchecked Sendable { func collect() throws -> DeviceData { let idfv = try resolveIDFV() - let trackingToken = storage.get(forKey: KeychainStorage.trackingTokenKey) + let storedID = storage.get(forKey: KeychainStorage.storedIDKey) return DeviceData( idfv: idfv, - trackingToken: trackingToken, + storedID: storedID, requestDurationMS: nil ) } diff --git a/Sources/MinFraudDevice/DeviceTracker.swift b/Sources/MinFraudDevice/DeviceTracker.swift index a448633..5d62dd4 100644 --- a/Sources/MinFraudDevice/DeviceTracker.swift +++ b/Sources/MinFraudDevice/DeviceTracker.swift @@ -6,15 +6,10 @@ public enum MinFraudDeviceError: Error, LocalizedError, Equatable { /// The Identifier for Vendor (IDFV) could not be obtained from the system or keychain. case idfvUnavailable - /// The server response did not include a tracking token. - case missingTrackingToken - public var errorDescription: String? { switch self { case .idfvUnavailable: return "Unable to obtain an Identifier for Vendor (IDFV)" - case .missingTrackingToken: - return "Server response did not include a tracking token" } } } @@ -81,30 +76,24 @@ public final class DeviceTracker: @unchecked Sendable { /// Collects device data and sends it to MaxMind servers. /// - /// On success, the tracking token is persisted in the keychain for - /// inclusion in subsequent requests. + /// On success, the stored ID is persisted in the keychain for + /// inclusion in subsequent requests, and returned as a tracking token. /// /// - Returns: A ``TrackingResult`` containing the tracking token. /// - Throws: ``MinFraudDeviceError/idfvUnavailable`` if the device - /// identifier cannot be obtained, ``MinFraudDeviceError/missingTrackingToken`` - /// if the server response does not include a token, or an ``APIError`` + /// identifier cannot be obtained, or an ``APIError`` /// if the network request fails. public func collectAndSend() async throws -> TrackingResult { let deviceData = try collector.collect() let response = try await apiClient.sendDeviceData(deviceData) - guard let token = response.trackingToken, - !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw MinFraudDeviceError.missingTrackingToken - } - - if storage.set(token, forKey: KeychainStorage.trackingTokenKey) { - logger?.debug("Cached tracking token from server response in keychain") + if storage.set(response.storedID, forKey: KeychainStorage.storedIDKey) { + logger?.debug("Cached stored ID from server response in keychain") } else { - logger?.warning("Failed to cache tracking token in keychain") + logger?.warning("Failed to cache stored ID in keychain") } - return TrackingResult(trackingToken: token) + return TrackingResult(trackingToken: response.storedID) } /// Cancels automatic collection and releases resources. diff --git a/Sources/MinFraudDevice/Model/DeviceData.swift b/Sources/MinFraudDevice/Model/DeviceData.swift index 105642c..dff6904 100644 --- a/Sources/MinFraudDevice/Model/DeviceData.swift +++ b/Sources/MinFraudDevice/Model/DeviceData.swift @@ -2,12 +2,12 @@ import Foundation struct DeviceData: Encodable, Sendable { let idfv: String - let trackingToken: String? + let storedID: String? let requestDurationMS: Int? enum CodingKeys: String, CodingKey { case idfv - case trackingToken = "tracking_token" + case storedID = "stored_id" case requestDurationMS = "request_duration" } } diff --git a/Sources/MinFraudDevice/Model/ServerResponse.swift b/Sources/MinFraudDevice/Model/ServerResponse.swift index c5d687c..aadeee8 100644 --- a/Sources/MinFraudDevice/Model/ServerResponse.swift +++ b/Sources/MinFraudDevice/Model/ServerResponse.swift @@ -1,11 +1,45 @@ import Foundation -struct ServerResponse: Decodable { - let trackingToken: String? - let ipVersion: Int? +struct ServerResponse: Decodable, Sendable { + let storedID: String + let ipVersion: Int enum CodingKeys: String, CodingKey { - case trackingToken = "tracking_token" + case storedID = "stored_id" case ipVersion = "ip_version" } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let rawStoredID = try container.decode(String.self, forKey: .storedID) + guard !rawStoredID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .storedID, + in: container, + debugDescription: "stored_id must not be blank" + ) + } + self.storedID = rawStoredID + + let rawIPVersion = try container.decode(Int.self, forKey: .ipVersion) + guard rawIPVersion == 4 || rawIPVersion == 6 else { + throw DecodingError.dataCorruptedError( + forKey: .ipVersion, + in: container, + debugDescription: "ip_version must be 4 or 6, got \(rawIPVersion)" + ) + } + self.ipVersion = rawIPVersion + } + + init(storedID: String, ipVersion: Int) { + precondition( + !storedID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "stored_id must not be blank" + ) + precondition(ipVersion == 4 || ipVersion == 6, "ip_version must be 4 or 6") + self.storedID = storedID + self.ipVersion = ipVersion + } } diff --git a/Sources/MinFraudDevice/Model/TrackingResult.swift b/Sources/MinFraudDevice/Model/TrackingResult.swift index 3d3540a..ca7f569 100644 --- a/Sources/MinFraudDevice/Model/TrackingResult.swift +++ b/Sources/MinFraudDevice/Model/TrackingResult.swift @@ -12,7 +12,10 @@ public struct TrackingResult: Sendable, CustomStringConvertible { public let trackingToken: String init(trackingToken: String) { - precondition(!trackingToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, "Tracking token must not be blank") + precondition( + !trackingToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "Tracking token must not be blank" + ) self.trackingToken = trackingToken } diff --git a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift index dd5f123..265ea93 100644 --- a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift +++ b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift @@ -61,7 +61,7 @@ final class DeviceAPIClient: Sendable { let ipv4URL = URL(string: "https://\(SDKConfig.defaultIPv4Host)\(SDKConfig.endpointPath)")! let dataWithDuration = DeviceData( idfv: deviceData.idfv, - trackingToken: deviceData.trackingToken, + storedID: deviceData.storedID, requestDurationMS: requestDurationMS ) do { diff --git a/Sources/MinFraudDevice/Storage/KeychainStorage.swift b/Sources/MinFraudDevice/Storage/KeychainStorage.swift index 2743c1d..81e1bcc 100644 --- a/Sources/MinFraudDevice/Storage/KeychainStorage.swift +++ b/Sources/MinFraudDevice/Storage/KeychainStorage.swift @@ -11,7 +11,7 @@ final class KeychainStorage: KeychainStoring, @unchecked Sendable { static let service = SDKConfig.identifier static let idfvKey = "idfv" - static let trackingTokenKey = "tracking_token" + static let storedIDKey = "stored_id" private let logger: Logger? diff --git a/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift b/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift index f1c20ae..9dae97a 100644 --- a/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift @@ -18,7 +18,7 @@ final class DeviceAPIClientTests: XCTestCase { } private var testDeviceData: DeviceData { - DeviceData(idfv: "test-idfv", trackingToken: "test-token", requestDurationMS: nil) + DeviceData(idfv: "test-idfv", storedID: "test-stored-id", requestDurationMS: nil) } private func makeClient( @@ -29,24 +29,37 @@ final class DeviceAPIClientTests: XCTestCase { return DeviceAPIClient(config: config, session: mockSession ?? .shared) } - func testSendDeviceDataSuccessResponses() async throws { + func testSendDeviceDataSuccess() async throws { + MockURLProtocol.requestHandler = { _ in + let response = HTTPURLResponse( + url: URL(string: "https://test.maxmind.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, Data("{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6}".utf8)) + } + + let client = makeClient() + let response = try await client.sendDeviceData(testDeviceData) + + XCTAssertEqual(response.storedID, "abc123:hmac456") + XCTAssertEqual(response.ipVersion, 6) + } + + func testSendDeviceDataThrowsOnInvalidResponse() async { struct Case { let label: String let json: String - let expectedToken: String? - let expectedIPVersion: Int? } let cases: [Case] = [ - Case(label: "valid values", - json: "{\"tracking_token\":\"abc123:hmac456\",\"ip_version\":6}", - expectedToken: "abc123:hmac456", expectedIPVersion: 6), - Case(label: "null values", - json: "{\"tracking_token\":null,\"ip_version\":null}", - expectedToken: nil, expectedIPVersion: nil), - Case(label: "empty object", - json: "{}", - expectedToken: nil, expectedIPVersion: nil) + Case(label: "missing stored_id", json: "{\"ip_version\":6}"), + Case(label: "null stored_id", json: "{\"stored_id\":null,\"ip_version\":6}"), + Case(label: "blank stored_id", json: "{\"stored_id\":\" \",\"ip_version\":6}"), + Case(label: "missing ip_version", json: "{\"stored_id\":\"abc\"}"), + Case(label: "invalid ip_version", json: "{\"stored_id\":\"abc\",\"ip_version\":5}"), + Case(label: "empty object", json: "{}") ] for tc in cases { @@ -61,10 +74,14 @@ final class DeviceAPIClientTests: XCTestCase { } let client = makeClient() - let response = try await client.sendDeviceData(testDeviceData) - - XCTAssertEqual(response.trackingToken, tc.expectedToken, "Failed for case: \(tc.label)") - XCTAssertEqual(response.ipVersion, tc.expectedIPVersion, "Failed for case: \(tc.label)") + do { + _ = try await client.sendDeviceData(testDeviceData) + XCTFail("Expected error for case: \(tc.label)") + } catch is DecodingError { + // Expected + } catch { + XCTFail("Expected DecodingError for case \(tc.label), got: \(error)") + } } } @@ -112,7 +129,7 @@ final class DeviceAPIClientTests: XCTestCase { capturedContentType = request.value(forHTTPHeaderField: "Content-Type") capturedURL = request.url let data = Data(""" - {"tracking_token":"abc123:hmac456","ip_version":4} + {"stored_id":"abc123:hmac456","ip_version":4} """.utf8) let response = HTTPURLResponse( url: request.url!, @@ -131,40 +148,39 @@ final class DeviceAPIClientTests: XCTestCase { XCTAssertNotNil(capturedBody) XCTAssertEqual(capturedBody?["account_id"] as? Int, 12345) XCTAssertEqual(capturedBody?["idfv"] as? String, "test-idfv") - XCTAssertEqual(capturedBody?["tracking_token"] as? String, "test-token") + XCTAssertEqual(capturedBody?["stored_id"] as? String, "test-stored-id") } func testRequestBodyEncodesDeviceDataFieldsFlat() throws { - let deviceData = DeviceData(idfv: "test-idfv", trackingToken: "test-token", requestDurationMS: 42) + let deviceData = DeviceData(idfv: "test-idfv", storedID: "test-stored-id", requestDurationMS: 42) let body = RequestBody(accountID: 123, deviceData: deviceData) let data = try JSONEncoder().encode(body) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) XCTAssertEqual(json["account_id"] as? Int, 123) XCTAssertEqual(json["idfv"] as? String, "test-idfv") - XCTAssertEqual(json["tracking_token"] as? String, "test-token") + XCTAssertEqual(json["stored_id"] as? String, "test-stored-id") XCTAssertEqual(json["request_duration"] as? Int, 42) XCTAssertNil(json["deviceData"], "DeviceData fields should be flat, not nested") } // MARK: - Dual Request Tests + private var validIPv6Response: String { + "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6}" + } + + private var validIPv4Response: String { + "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":4}" + } + func testDualRequestSendsToIPv6AndIPv4Endpoints() async throws { var capturedURLs: [String] = [] var requestCount = 0 MockURLProtocol.requestHandler = { request in requestCount += 1 capturedURLs.append(request.url?.absoluteString ?? "") - let body: String - if requestCount == 1 { - body = """ - {"tracking_token":"abc123:hmac456","ip_version":6} - """ - } else { - body = """ - {"tracking_token":"abc123:hmac456","ip_version":4} - """ - } + let body = requestCount == 1 ? self.validIPv6Response : self.validIPv4Response let response = HTTPURLResponse( url: request.url!, statusCode: 200, @@ -191,16 +207,7 @@ final class DeviceAPIClientTests: XCTestCase { let json = try? JSONSerialization.jsonObject(with: bodyData) as? [String: Any] { capturedBodies.append(json) } - let body: String - if requestCount == 1 { - body = """ - {"tracking_token":"abc123:hmac456","ip_version":6} - """ - } else { - body = """ - {"tracking_token":"abc123:hmac456","ip_version":4} - """ - } + let body = requestCount == 1 ? self.validIPv6Response : self.validIPv4Response let response = HTTPURLResponse( url: request.url!, statusCode: 200, @@ -225,9 +232,7 @@ final class DeviceAPIClientTests: XCTestCase { var requestCount = 0 MockURLProtocol.requestHandler = { request in requestCount += 1 - let data = Data(""" - {"tracking_token":"abc123:hmac456","ip_version":4} - """.utf8) + let data = Data(self.validIPv4Response.utf8) let response = HTTPURLResponse( url: request.url!, statusCode: 200, @@ -248,9 +253,7 @@ final class DeviceAPIClientTests: XCTestCase { MockURLProtocol.requestHandler = { request in requestCount += 1 if requestCount == 1 { - let data = Data(""" - {"tracking_token":"abc123:hmac456","ip_version":6} - """.utf8) + let data = Data(self.validIPv6Response.utf8) let response = HTTPURLResponse( url: request.url!, statusCode: 200, @@ -259,9 +262,7 @@ final class DeviceAPIClientTests: XCTestCase { )! return (response, data) } else { - let data = Data(""" - {"error":"Server Error"} - """.utf8) + let data = Data("{\"error\":\"Server Error\"}".utf8) let response = HTTPURLResponse( url: request.url!, statusCode: 500, @@ -275,7 +276,7 @@ final class DeviceAPIClientTests: XCTestCase { let client = makeClient(serverURL: nil) let response = try await client.sendDeviceData(testDeviceData) - XCTAssertEqual(response.trackingToken, "abc123:hmac456") + XCTAssertEqual(response.storedID, "abc123:hmac456") XCTAssertEqual(requestCount, 2) } } diff --git a/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift b/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift index a0a713a..8047f6e 100644 --- a/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift @@ -60,9 +60,9 @@ final class DeviceDataCollectorTests: XCTestCase { } } - func testCollectIncludesTrackingTokenFromKeychain() throws { + func testCollectIncludesStoredIDFromKeychain() throws { let storage = MockKeychainStorage() - _ = storage.set("existing-token", forKey: KeychainStorage.trackingTokenKey) + _ = storage.set("existing-stored-id", forKey: KeychainStorage.storedIDKey) let collector = DeviceDataCollector( storage: storage, idfvProvider: { "IDFV" } @@ -70,10 +70,10 @@ final class DeviceDataCollectorTests: XCTestCase { let data = try collector.collect() - XCTAssertEqual(data.trackingToken, "existing-token") + XCTAssertEqual(data.storedID, "existing-stored-id") } - func testCollectReturnsNilTrackingTokenWhenNotStored() throws { + func testCollectReturnsNilStoredIDWhenNotStored() throws { let storage = MockKeychainStorage() let collector = DeviceDataCollector( storage: storage, @@ -82,7 +82,7 @@ final class DeviceDataCollectorTests: XCTestCase { let data = try collector.collect() - XCTAssertNil(data.trackingToken) + XCTAssertNil(data.storedID) } func testCollectReturnsNilRequestDuration() throws { diff --git a/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift b/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift index dc4c30e..a597132 100644 --- a/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift @@ -22,6 +22,10 @@ final class DeviceTrackerTests: XCTestCase { super.tearDown() } + private var validResponse: String { + "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6}" + } + private func makeTracker( accountID: Int = 99999, serverURL: URL? = URL(string: "https://test.maxmind.com"), @@ -49,16 +53,13 @@ final class DeviceTrackerTests: XCTestCase { func testCollectAndSendReturnsTrackingResult() async throws { MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":"abc123:hmac456","ip_version":6} - """.utf8) let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] )! - return (response, data) + return (response, Data(self.validResponse.utf8)) } let tracker = makeTracker() @@ -67,88 +68,32 @@ final class DeviceTrackerTests: XCTestCase { XCTAssertEqual(result.trackingToken, "abc123:hmac456") } - func testCollectAndSendSavesTrackingTokenToKeychain() async throws { + func testCollectAndSendSavesStoredIDToKeychain() async throws { MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":"saved-token"} - """.utf8) let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] )! - return (response, data) + return (response, Data(self.validResponse.utf8)) } let tracker = makeTracker() _ = try await tracker.collectAndSend() - XCTAssertEqual(mockStorage.get(forKey: KeychainStorage.trackingTokenKey), "saved-token") - } - - func testCollectAndSendThrowsOnMissingTrackingToken() async { - MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":null} - """.utf8) - let response = HTTPURLResponse( - url: URL(string: "https://test.maxmind.com")!, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )! - return (response, data) - } - - let tracker = makeTracker() - do { - _ = try await tracker.collectAndSend() - XCTFail("Expected error to be thrown") - } catch is MinFraudDeviceError { - // Expected - } catch { - XCTFail("Unexpected error type: \(error)") - } - } - - func testCollectAndSendThrowsOnBlankTrackingToken() async { - MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":" "} - """.utf8) - let response = HTTPURLResponse( - url: URL(string: "https://test.maxmind.com")!, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )! - return (response, data) - } - - let tracker = makeTracker() - do { - _ = try await tracker.collectAndSend() - XCTFail("Expected error to be thrown") - } catch is MinFraudDeviceError { - // Expected - } catch { - XCTFail("Unexpected error type: \(error)") - } + XCTAssertEqual(mockStorage.get(forKey: KeychainStorage.storedIDKey), "abc123:hmac456") } func testCollectAndSendSucceedsWhenStorageSaveFails() async throws { MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":"abc123:hmac456"} - """.utf8) let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] )! - return (response, data) + return (response, Data(self.validResponse.utf8)) } mockStorage.shouldFailOnSet = true @@ -160,16 +105,13 @@ final class DeviceTrackerTests: XCTestCase { func testCollectAndSendPropagatesAPIError() async { MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"error":"Server Error"} - """.utf8) let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 500, httpVersion: nil, headerFields: nil )! - return (response, data) + return (response, Data("{}".utf8)) } let tracker = makeTracker() @@ -218,16 +160,13 @@ final class DeviceTrackerTests: XCTestCase { func testShutdownCancelsAutomaticCollection() async throws { MockURLProtocol.requestHandler = { _ in - let data = Data(""" - {"tracking_token":"test"} - """.utf8) let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"] )! - return (response, data) + return (response, Data(self.validResponse.utf8)) } let tracker = makeTracker(collectionIntervalSeconds: 300) diff --git a/Tests/MinFraudDeviceTests/ModelTests.swift b/Tests/MinFraudDeviceTests/ModelTests.swift index 329c369..fd2e94c 100644 --- a/Tests/MinFraudDeviceTests/ModelTests.swift +++ b/Tests/MinFraudDeviceTests/ModelTests.swift @@ -6,23 +6,23 @@ final class ModelTests: XCTestCase { func testDeviceDataEncoding() throws { struct Case { let label: String - let trackingToken: String? + let storedID: String? let requestDurationMS: Int? - let expectedToken: String? + let expectedStoredID: String? let expectedDuration: Int? } let cases: [Case] = [ - Case(label: "all fields", trackingToken: "abc123:hmac456", - requestDurationMS: 42, expectedToken: "abc123:hmac456", expectedDuration: 42), - Case(label: "nil fields omitted", trackingToken: nil, - requestDurationMS: nil, expectedToken: nil, expectedDuration: nil) + Case(label: "all fields", storedID: "abc123:hmac456", + requestDurationMS: 42, expectedStoredID: "abc123:hmac456", expectedDuration: 42), + Case(label: "nil fields omitted", storedID: nil, + requestDurationMS: nil, expectedStoredID: nil, expectedDuration: nil) ] for tc in cases { let data = DeviceData( idfv: "test-idfv", - trackingToken: tc.trackingToken, + storedID: tc.storedID, requestDurationMS: tc.requestDurationMS ) let encoded = try JSONEncoder().encode(data) @@ -32,40 +32,55 @@ final class ModelTests: XCTestCase { ) XCTAssertEqual(json["idfv"] as? String, "test-idfv", "Failed for case: \(tc.label)") - XCTAssertEqual(json["tracking_token"] as? String, tc.expectedToken, "Failed for case: \(tc.label)") + XCTAssertEqual(json["stored_id"] as? String, tc.expectedStoredID, "Failed for case: \(tc.label)") XCTAssertEqual(json["request_duration"] as? Int, tc.expectedDuration, "Failed for case: \(tc.label)") } } func testServerResponseDecoding() throws { - struct Case { - let label: String - let json: String - let expectedToken: String? - let expectedIPVersion: Int? - } + let json = "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6}" + let response = try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8)) - let cases: [Case] = [ - Case(label: "valid values", - json: "{\"tracking_token\":\"abc123:hmac456\",\"ip_version\":6}", - expectedToken: "abc123:hmac456", expectedIPVersion: 6), - Case(label: "null values", - json: "{\"tracking_token\":null,\"ip_version\":null}", - expectedToken: nil, expectedIPVersion: nil), - Case(label: "missing values", - json: "{}", - expectedToken: nil, expectedIPVersion: nil), - Case(label: "unknown fields ignored", - json: "{\"tracking_token\":\"abc123:hmac456\",\"ip_version\":6,\"unknown\":\"value\"}", - expectedToken: "abc123:hmac456", expectedIPVersion: 6) - ] + XCTAssertEqual(response.storedID, "abc123:hmac456") + XCTAssertEqual(response.ipVersion, 6) + } - for tc in cases { - let response = try JSONDecoder().decode(ServerResponse.self, from: Data(tc.json.utf8)) + func testServerResponseIgnoresUnknownFields() throws { + let json = "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6,\"unknown\":\"value\"}" + let response = try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8)) - XCTAssertEqual(response.trackingToken, tc.expectedToken, "Failed for case: \(tc.label)") - XCTAssertEqual(response.ipVersion, tc.expectedIPVersion, "Failed for case: \(tc.label)") - } + XCTAssertEqual(response.storedID, "abc123:hmac456") + XCTAssertEqual(response.ipVersion, 6) + } + + func testServerResponseRejectsMissingStoredID() { + let json = "{\"ip_version\":6}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) + } + + func testServerResponseRejectsNullStoredID() { + let json = "{\"stored_id\":null,\"ip_version\":6}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) + } + + func testServerResponseRejectsBlankStoredID() { + let json = "{\"stored_id\":\" \",\"ip_version\":6}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) + } + + func testServerResponseRejectsMissingIPVersion() { + let json = "{\"stored_id\":\"abc123:hmac456\"}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) + } + + func testServerResponseRejectsInvalidIPVersion() { + let json = "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":5}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) + } + + func testServerResponseRejectsEmptyObject() { + let json = "{}" + XCTAssertThrowsError(try JSONDecoder().decode(ServerResponse.self, from: Data(json.utf8))) } func testTrackingResultStoresToken() { From 178a538932c97a3fd3596618286085c81d8b73db Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Fri, 3 Apr 2026 15:07:17 -0400 Subject: [PATCH 06/31] Add a CI check for breaking public API changes --- .github/workflows/api-compat.yml | 54 ++ api-baseline.json | 1054 ++++++++++++++++++++++++++++++ 2 files changed, 1108 insertions(+) create mode 100644 .github/workflows/api-compat.yml create mode 100644 api-baseline.json diff --git a/.github/workflows/api-compat.yml b/.github/workflows/api-compat.yml new file mode 100644 index 0000000..aaae846 --- /dev/null +++ b/.github/workflows/api-compat.yml @@ -0,0 +1,54 @@ +name: API Compatibility + +on: [push, pull_request] + +permissions: {} + +jobs: + check-api-compat: + name: Check for API Breaking Changes + runs-on: macos-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: latest-stable + + - name: Build + run: | + xcodebuild build \ + -scheme MinFraudDevice \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath .build/api-compat \ + ONLY_ACTIVE_ARCH=NO \ + | xcpretty && exit ${PIPESTATUS[0]} + + - name: Check API compatibility + run: | + DIAGS=$(xcrun swift-api-digester -diagnose-sdk \ + -module MinFraudDevice \ + -baseline-path api-baseline.json \ + -sdk "$(xcrun --show-sdk-path --sdk iphonesimulator)" \ + -target arm64-apple-ios15.0-simulator \ + -I .build/api-compat/Build/Products/Debug-iphonesimulator \ + -swift-only \ + -compiler-style-diags 2>&1) + + if echo "$DIAGS" | grep -q "API breakage:"; then + echo "::error::API breaking changes detected:" + echo "$DIAGS" | grep "API breakage:" + echo "" + echo "If this change is intentional, update api-baseline.json in your PR by" + echo "regenerating it with xcrun swift-api-digester -dump-sdk." + exit 1 + fi + + echo "No API breaking changes detected." diff --git a/api-baseline.json b/api-baseline.json new file mode 100644 index 0000000..b337227 --- /dev/null +++ b/api-baseline.json @@ -0,0 +1,1054 @@ +{ + "ABIRoot": { + "kind": "Root", + "name": "MinFraudDevice", + "printedName": "MinFraudDevice", + "children": [ + { + "kind": "Import", + "name": "Foundation", + "printedName": "Foundation", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Import", + "name": "Foundation", + "printedName": "Foundation", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Import", + "name": "Foundation", + "printedName": "Foundation", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Import", + "name": "Foundation", + "printedName": "Foundation", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Import", + "name": "Security", + "printedName": "Security", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Import", + "name": "SwiftOnoneSupport", + "printedName": "SwiftOnoneSupport", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Import", + "name": "UIKit", + "printedName": "UIKit", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Import", + "name": "_Concurrency", + "printedName": "_Concurrency", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Import", + "name": "_StringProcessing", + "printedName": "_StringProcessing", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Import", + "name": "_SwiftConcurrencyShims", + "printedName": "_SwiftConcurrencyShims", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Import", + "name": "os", + "printedName": "os", + "declKind": "Import", + "moduleName": "MinFraudDevice" + }, + { + "kind": "TypeDecl", + "name": "SDKConfig", + "printedName": "SDKConfig", + "children": [ + { + "kind": "Var", + "name": "accountID", + "printedName": "accountID", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Var", + "usr": "s:14MinFraudDevice9SDKConfigV9accountIDSivp", + "mangledName": "$s14MinFraudDevice9SDKConfigV9accountIDSivp", + "moduleName": "MinFraudDevice", + "declAttributes": [ + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Accessor", + "usr": "s:14MinFraudDevice9SDKConfigV9accountIDSivg", + "mangledName": "$s14MinFraudDevice9SDKConfigV9accountIDSivg", + "moduleName": "MinFraudDevice", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Var", + "name": "serverURL", + "printedName": "serverURL", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Foundation.URL?", + "children": [ + { + "kind": "TypeNominal", + "name": "URL", + "printedName": "Foundation.URL", + "usr": "s:10Foundation3URLV" + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Var", + "usr": "s:14MinFraudDevice9SDKConfigV9serverURL10Foundation0F0VSgvp", + "mangledName": "$s14MinFraudDevice9SDKConfigV9serverURL10Foundation0F0VSgvp", + "moduleName": "MinFraudDevice", + "declAttributes": [ + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Foundation.URL?", + "children": [ + { + "kind": "TypeNominal", + "name": "URL", + "printedName": "Foundation.URL", + "usr": "s:10Foundation3URLV" + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:14MinFraudDevice9SDKConfigV9serverURL10Foundation0F0VSgvg", + "mangledName": "$s14MinFraudDevice9SDKConfigV9serverURL10Foundation0F0VSgvg", + "moduleName": "MinFraudDevice", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Var", + "name": "loggingEnabled", + "printedName": "loggingEnabled", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Var", + "usr": "s:14MinFraudDevice9SDKConfigV14loggingEnabledSbvp", + "mangledName": "$s14MinFraudDevice9SDKConfigV14loggingEnabledSbvp", + "moduleName": "MinFraudDevice", + "declAttributes": [ + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Accessor", + "usr": "s:14MinFraudDevice9SDKConfigV14loggingEnabledSbvg", + "mangledName": "$s14MinFraudDevice9SDKConfigV14loggingEnabledSbvg", + "moduleName": "MinFraudDevice", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Var", + "name": "collectionIntervalSeconds", + "printedName": "collectionIntervalSeconds", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Var", + "usr": "s:14MinFraudDevice9SDKConfigV25collectionIntervalSecondsSivp", + "mangledName": "$s14MinFraudDevice9SDKConfigV25collectionIntervalSecondsSivp", + "moduleName": "MinFraudDevice", + "declAttributes": [ + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Accessor", + "usr": "s:14MinFraudDevice9SDKConfigV25collectionIntervalSecondsSivg", + "mangledName": "$s14MinFraudDevice9SDKConfigV25collectionIntervalSecondsSivg", + "moduleName": "MinFraudDevice", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(accountID:serverURL:loggingEnabled:collectionIntervalSeconds:)", + "children": [ + { + "kind": "TypeNominal", + "name": "SDKConfig", + "printedName": "MinFraudDevice.SDKConfig", + "usr": "s:14MinFraudDevice9SDKConfigV" + }, + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + }, + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Foundation.URL?", + "children": [ + { + "kind": "TypeNominal", + "name": "URL", + "printedName": "Foundation.URL", + "usr": "s:10Foundation3URLV" + } + ], + "hasDefaultArg": true, + "usr": "s:Sq" + }, + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "hasDefaultArg": true, + "usr": "s:Sb" + }, + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "hasDefaultArg": true, + "usr": "s:Si" + } + ], + "declKind": "Constructor", + "usr": "s:14MinFraudDevice9SDKConfigV9accountID9serverURL14loggingEnabled25collectionIntervalSecondsACSi_10Foundation0H0VSgSbSitcfc", + "mangledName": "$s14MinFraudDevice9SDKConfigV9accountID9serverURL14loggingEnabled25collectionIntervalSecondsACSi_10Foundation0H0VSgSbSitcfc", + "moduleName": "MinFraudDevice", + "init_kind": "Designated" + } + ], + "declKind": "Struct", + "usr": "s:14MinFraudDevice9SDKConfigV", + "mangledName": "$s14MinFraudDevice9SDKConfigV", + "moduleName": "MinFraudDevice", + "conformances": [ + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] + }, + { + "kind": "TypeDecl", + "name": "MinFraudDeviceError", + "printedName": "MinFraudDeviceError", + "children": [ + { + "kind": "Var", + "name": "idfvUnavailable", + "printedName": "idfvUnavailable", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(MinFraudDevice.MinFraudDeviceError.Type) -> MinFraudDevice.MinFraudDeviceError", + "children": [ + { + "kind": "TypeNominal", + "name": "MinFraudDeviceError", + "printedName": "MinFraudDevice.MinFraudDeviceError", + "usr": "s:14MinFraudDevice0abC5ErrorO" + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "MinFraudDevice.MinFraudDeviceError.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "MinFraudDeviceError", + "printedName": "MinFraudDevice.MinFraudDeviceError", + "usr": "s:14MinFraudDevice0abC5ErrorO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:14MinFraudDevice0abC5ErrorO15idfvUnavailableyA2CmF", + "mangledName": "$s14MinFraudDevice0abC5ErrorO15idfvUnavailableyA2CmF", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Var", + "name": "errorDescription", + "printedName": "errorDescription", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Var", + "usr": "s:14MinFraudDevice0abC5ErrorO16errorDescriptionSSSgvp", + "mangledName": "$s14MinFraudDevice0abC5ErrorO16errorDescriptionSSSgvp", + "moduleName": "MinFraudDevice", + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:14MinFraudDevice0abC5ErrorO16errorDescriptionSSSgvg", + "mangledName": "$s14MinFraudDevice0abC5ErrorO16errorDescriptionSSSgvg", + "moduleName": "MinFraudDevice", + "accessorKind": "get" + } + ] + }, + { + "kind": "Function", + "name": "__derived_enum_equals", + "printedName": "__derived_enum_equals(_:_:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + }, + { + "kind": "TypeNominal", + "name": "MinFraudDeviceError", + "printedName": "MinFraudDevice.MinFraudDeviceError", + "usr": "s:14MinFraudDevice0abC5ErrorO" + }, + { + "kind": "TypeNominal", + "name": "MinFraudDeviceError", + "printedName": "MinFraudDevice.MinFraudDeviceError", + "usr": "s:14MinFraudDevice0abC5ErrorO" + } + ], + "declKind": "Func", + "usr": "s:14MinFraudDevice0abC5ErrorO21__derived_enum_equalsySbAC_ACtFZ", + "mangledName": "$s14MinFraudDevice0abC5ErrorO21__derived_enum_equalsySbAC_ACtFZ", + "moduleName": "MinFraudDevice", + "static": true, + "implicit": true, + "declAttributes": [ + "Implements" + ], + "funcSelfKind": "NonMutating" + }, + { + "kind": "Function", + "name": "hash", + "printedName": "hash(into:)", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Hasher", + "printedName": "Swift.Hasher", + "paramValueOwnership": "InOut", + "usr": "s:s6HasherV" + } + ], + "declKind": "Func", + "usr": "s:14MinFraudDevice0abC5ErrorO4hash4intoys6HasherVz_tF", + "mangledName": "$s14MinFraudDevice0abC5ErrorO4hash4intoys6HasherVz_tF", + "moduleName": "MinFraudDevice", + "implicit": true, + "funcSelfKind": "NonMutating" + }, + { + "kind": "Var", + "name": "hashValue", + "printedName": "hashValue", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Var", + "usr": "s:14MinFraudDevice0abC5ErrorO9hashValueSivp", + "mangledName": "$s14MinFraudDevice0abC5ErrorO9hashValueSivp", + "moduleName": "MinFraudDevice", + "implicit": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + } + ], + "declKind": "Accessor", + "usr": "s:14MinFraudDevice0abC5ErrorO9hashValueSivg", + "mangledName": "$s14MinFraudDevice0abC5ErrorO9hashValueSivg", + "moduleName": "MinFraudDevice", + "implicit": true, + "accessorKind": "get" + } + ] + } + ], + "declKind": "Enum", + "usr": "s:14MinFraudDevice0abC5ErrorO", + "mangledName": "$s14MinFraudDevice0abC5ErrorO", + "moduleName": "MinFraudDevice", + "isEnumExhaustive": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Hashable", + "printedName": "Hashable", + "usr": "s:SH", + "mangledName": "$sSH" + }, + { + "kind": "Conformance", + "name": "Error", + "printedName": "Error", + "usr": "s:s5ErrorP", + "mangledName": "$ss5ErrorP" + }, + { + "kind": "Conformance", + "name": "LocalizedError", + "printedName": "LocalizedError", + "usr": "s:10Foundation14LocalizedErrorP", + "mangledName": "$s10Foundation14LocalizedErrorP" + }, + { + "kind": "Conformance", + "name": "Equatable", + "printedName": "Equatable", + "usr": "s:SQ", + "mangledName": "$sSQ" + }, + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] + }, + { + "kind": "TypeDecl", + "name": "DeviceTracker", + "printedName": "DeviceTracker", + "children": [ + { + "kind": "Constructor", + "name": "init", + "printedName": "init(config:)", + "children": [ + { + "kind": "TypeNominal", + "name": "DeviceTracker", + "printedName": "MinFraudDevice.DeviceTracker", + "usr": "s:14MinFraudDevice0C7TrackerC" + }, + { + "kind": "TypeNominal", + "name": "SDKConfig", + "printedName": "MinFraudDevice.SDKConfig", + "usr": "s:14MinFraudDevice9SDKConfigV" + } + ], + "declKind": "Constructor", + "usr": "s:14MinFraudDevice0C7TrackerC6configAcA9SDKConfigV_tcfc", + "mangledName": "$s14MinFraudDevice0C7TrackerC6configAcA9SDKConfigV_tcfc", + "moduleName": "MinFraudDevice", + "init_kind": "Designated" + }, + { + "kind": "Function", + "name": "collectAndSend", + "printedName": "collectAndSend()", + "children": [ + { + "kind": "TypeNominal", + "name": "TrackingResult", + "printedName": "MinFraudDevice.TrackingResult", + "usr": "s:14MinFraudDevice14TrackingResultV" + } + ], + "declKind": "Func", + "usr": "s:14MinFraudDevice0C7TrackerC14collectAndSendAA14TrackingResultVyYaKF", + "mangledName": "$s14MinFraudDevice0C7TrackerC14collectAndSendAA14TrackingResultVyYaKF", + "moduleName": "MinFraudDevice", + "declAttributes": [ + "Final" + ], + "throwing": true, + "funcSelfKind": "NonMutating" + }, + { + "kind": "Function", + "name": "shutdown", + "printedName": "shutdown()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ], + "declKind": "Func", + "usr": "s:14MinFraudDevice0C7TrackerC8shutdownyyF", + "mangledName": "$s14MinFraudDevice0C7TrackerC8shutdownyyF", + "moduleName": "MinFraudDevice", + "declAttributes": [ + "Final" + ], + "funcSelfKind": "NonMutating" + } + ], + "declKind": "Class", + "usr": "s:14MinFraudDevice0C7TrackerC", + "mangledName": "$s14MinFraudDevice0C7TrackerC", + "moduleName": "MinFraudDevice", + "declAttributes": [ + "Final" + ], + "hasMissingDesignatedInitializers": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] + }, + { + "kind": "TypeDecl", + "name": "TrackingResult", + "printedName": "TrackingResult", + "children": [ + { + "kind": "Var", + "name": "trackingToken", + "printedName": "trackingToken", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "s:14MinFraudDevice14TrackingResultV13trackingTokenSSvp", + "mangledName": "$s14MinFraudDevice14TrackingResultV13trackingTokenSSvp", + "moduleName": "MinFraudDevice", + "declAttributes": [ + "HasStorage" + ], + "isLet": true, + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Accessor", + "usr": "s:14MinFraudDevice14TrackingResultV13trackingTokenSSvg", + "mangledName": "$s14MinFraudDevice14TrackingResultV13trackingTokenSSvg", + "moduleName": "MinFraudDevice", + "implicit": true, + "declAttributes": [ + "Transparent" + ], + "accessorKind": "get" + } + ] + }, + { + "kind": "Var", + "name": "description", + "printedName": "description", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Var", + "usr": "s:14MinFraudDevice14TrackingResultV11descriptionSSvp", + "mangledName": "$s14MinFraudDevice14TrackingResultV11descriptionSSvp", + "moduleName": "MinFraudDevice", + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Accessor", + "usr": "s:14MinFraudDevice14TrackingResultV11descriptionSSvg", + "mangledName": "$s14MinFraudDevice14TrackingResultV11descriptionSSvg", + "moduleName": "MinFraudDevice", + "accessorKind": "get" + } + ] + } + ], + "declKind": "Struct", + "usr": "s:14MinFraudDevice14TrackingResultV", + "mangledName": "$s14MinFraudDevice14TrackingResultV", + "moduleName": "MinFraudDevice", + "conformances": [ + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "CustomStringConvertible", + "printedName": "CustomStringConvertible", + "usr": "s:s23CustomStringConvertibleP", + "mangledName": "$ss23CustomStringConvertibleP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] + }, + { + "kind": "TypeDecl", + "name": "APIError", + "printedName": "APIError", + "children": [ + { + "kind": "Var", + "name": "serverError", + "printedName": "serverError", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(MinFraudDevice.APIError.Type) -> (Swift.Int, Swift.String) -> MinFraudDevice.APIError", + "children": [ + { + "kind": "TypeFunc", + "name": "Function", + "printedName": "(Swift.Int, Swift.String) -> MinFraudDevice.APIError", + "children": [ + { + "kind": "TypeNominal", + "name": "APIError", + "printedName": "MinFraudDevice.APIError", + "usr": "s:14MinFraudDevice8APIErrorO" + }, + { + "kind": "TypeNominal", + "name": "Tuple", + "printedName": "(statusCode: Swift.Int, message: Swift.String)", + "children": [ + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ] + } + ] + }, + { + "kind": "TypeNominal", + "name": "Metatype", + "printedName": "MinFraudDevice.APIError.Type", + "children": [ + { + "kind": "TypeNominal", + "name": "APIError", + "printedName": "MinFraudDevice.APIError", + "usr": "s:14MinFraudDevice8APIErrorO" + } + ] + } + ] + } + ], + "declKind": "EnumElement", + "usr": "s:14MinFraudDevice8APIErrorO11serverErroryACSi_SStcACmF", + "mangledName": "$s14MinFraudDevice8APIErrorO11serverErroryACSi_SStcACmF", + "moduleName": "MinFraudDevice" + }, + { + "kind": "Var", + "name": "errorDescription", + "printedName": "errorDescription", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Var", + "usr": "s:14MinFraudDevice8APIErrorO16errorDescriptionSSSgvp", + "mangledName": "$s14MinFraudDevice8APIErrorO16errorDescriptionSSSgvp", + "moduleName": "MinFraudDevice", + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Accessor", + "usr": "s:14MinFraudDevice8APIErrorO16errorDescriptionSSSgvg", + "mangledName": "$s14MinFraudDevice8APIErrorO16errorDescriptionSSSgvg", + "moduleName": "MinFraudDevice", + "accessorKind": "get" + } + ] + } + ], + "declKind": "Enum", + "usr": "s:14MinFraudDevice8APIErrorO", + "mangledName": "$s14MinFraudDevice8APIErrorO", + "moduleName": "MinFraudDevice", + "isEnumExhaustive": true, + "conformances": [ + { + "kind": "Conformance", + "name": "Error", + "printedName": "Error", + "usr": "s:s5ErrorP", + "mangledName": "$ss5ErrorP" + }, + { + "kind": "Conformance", + "name": "LocalizedError", + "printedName": "LocalizedError", + "usr": "s:10Foundation14LocalizedErrorP", + "mangledName": "$s10Foundation14LocalizedErrorP" + }, + { + "kind": "Conformance", + "name": "Sendable", + "printedName": "Sendable", + "usr": "s:s8SendableP", + "mangledName": "$ss8SendableP" + }, + { + "kind": "Conformance", + "name": "SendableMetatype", + "printedName": "SendableMetatype", + "usr": "s:s16SendableMetatypeP", + "mangledName": "$ss16SendableMetatypeP" + }, + { + "kind": "Conformance", + "name": "Copyable", + "printedName": "Copyable", + "usr": "s:s8CopyableP", + "mangledName": "$ss8CopyableP" + }, + { + "kind": "Conformance", + "name": "Escapable", + "printedName": "Escapable", + "usr": "s:s9EscapableP", + "mangledName": "$ss9EscapableP" + } + ] + } + ], + "json_format_version": 8 + } +} \ No newline at end of file From 688402c6f64f61371ba6be169b91567e1d3ae4ac Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Fri, 3 Apr 2026 15:10:36 -0400 Subject: [PATCH 07/31] Fixup release instructions w.r.t. location of version constant --- README.dev.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.dev.md b/README.dev.md index 61ba713..1a00002 100644 --- a/README.dev.md +++ b/README.dev.md @@ -10,8 +10,8 @@ git checkout -b release/X.Y.Z ``` -2. Update the version string in the User-Agent header in - `Sources/MinFraudDevice/Network/DeviceAPIClient.swift`. +2. Update the version constant in + `Sources/MinFraudDevice/Config/SDKConfig.swift`. 3. Update the version in the README.md installation example if needed. 4. Update `CHANGELOG.md`: set the release date and document any final changes. 5. Verify the privacy manifest is up to date From dde6786f0f1a147e84417bb94a075f5c1b16c8dd Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Fri, 3 Apr 2026 16:12:17 -0400 Subject: [PATCH 08/31] Add a thin wrapper layer to support integration with Obj-C apps --- CLAUDE.md | 4 + Package.swift | 4 + README.md | 22 ++++ Sources/MinFraudDevice/DeviceTracker.swift | 10 ++ .../Network/DeviceAPIClient.swift | 17 +++ .../MinFraudDevice/ObjC/MMDeviceTracker.swift | 42 ++++++ Sources/MinFraudDevice/ObjC/MMSDKConfig.swift | 46 +++++++ .../ObjC/MMTrackingResult.swift | 19 +++ .../ObjCWrapperTests.m | 44 +++++++ .../ObjCWrapperTests.swift | 122 ++++++++++++++++++ 10 files changed, 330 insertions(+) create mode 100644 Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift create mode 100644 Sources/MinFraudDevice/ObjC/MMSDKConfig.swift create mode 100644 Sources/MinFraudDevice/ObjC/MMTrackingResult.swift create mode 100644 Tests/MinFraudDeviceObjCTests/ObjCWrapperTests.m create mode 100644 Tests/MinFraudDeviceTests/ObjCWrapperTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 47fd254..a947610 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,10 @@ xcodebuild build \ Unlike the Android sibling SDK, there is no singleton pattern. Users create instances directly. +Objective-C compatible wrappers (`MMSDKConfig`, `MMDeviceTracker`, +`MMTrackingResult`) live in `ObjC/` and wrap the Swift types with `NSObject` +subclasses and completion-handler APIs. + ### Four-Layer Architecture 1. **Public API Layer** (`DeviceTracker.swift`) diff --git a/Package.swift b/Package.swift index 5390f96..60575bd 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,10 @@ let package = Package( .testTarget( name: "MinFraudDeviceTests", dependencies: ["MinFraudDevice"] + ), + .testTarget( + name: "MinFraudDeviceObjCTests", + dependencies: ["MinFraudDevice"] ) ] ) diff --git a/README.md b/README.md index 0b0eeac..c1cef40 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,28 @@ Call `shutdown()` to cancel automatic collection and release resources: tracker.shutdown() ``` +### Objective-C + +The SDK provides Objective-C compatible wrapper classes with an `MM` prefix. + +```objc +@import MinFraudDevice; + +MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:123456]; +MMDeviceTracker *tracker = [[MMDeviceTracker alloc] initWithConfig:config]; + +[tracker collectAndSendWithCompletion:^(MMTrackingResult *result, NSError *error) { + if (error) { + NSLog(@"Failed to send device data: %@", error); + return; + } + [self sendToBackend:result.trackingToken]; +}]; + +// When done: +[tracker shutdown]; +``` + ## Privacy The SDK collects the Identifier for Vendor (IDFV) and persists it in the diff --git a/Sources/MinFraudDevice/DeviceTracker.swift b/Sources/MinFraudDevice/DeviceTracker.swift index 5d62dd4..0efa1b7 100644 --- a/Sources/MinFraudDevice/DeviceTracker.swift +++ b/Sources/MinFraudDevice/DeviceTracker.swift @@ -14,6 +14,16 @@ public enum MinFraudDeviceError: Error, LocalizedError, Equatable { } } +extension MinFraudDeviceError: CustomNSError { + public static var errorDomain: String { SDKConfig.identifier } + + public var errorCode: Int { + switch self { + case .idfvUnavailable: return 1 + } + } +} + /// Main entry point for the MinFraud Device SDK. /// /// Create an instance with an ``SDKConfig``, then call ``collectAndSend()`` diff --git a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift index 265ea93..649a301 100644 --- a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift +++ b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift @@ -29,6 +29,23 @@ public enum APIError: Error, LocalizedError { } } +extension APIError: CustomNSError { + public static var errorDomain: String { "\(SDKConfig.identifier).api" } + + public var errorCode: Int { + switch self { + case .serverError(let statusCode, _): return statusCode + } + } + + public var errorUserInfo: [String: Any] { + switch self { + case .serverError(_, let message): + return [NSLocalizedDescriptionKey: errorDescription ?? message] + } + } +} + final class DeviceAPIClient: Sendable { private let config: SDKConfig private let session: URLSession diff --git a/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift new file mode 100644 index 0000000..952050f --- /dev/null +++ b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Objective-C compatible entry point for the MinFraud Device SDK. +/// +/// This class wraps ``DeviceTracker`` for use from Objective-C code. +/// Swift callers should use ``DeviceTracker`` directly. +@objc(MMDeviceTracker) +public final class ObjCDeviceTracker: NSObject { + private let tracker: DeviceTracker + + /// Creates a new device tracker with the given configuration. + /// + /// - Parameter config: The SDK configuration. + @objc + public init(config: ObjCSDKConfig) { + self.tracker = DeviceTracker(config: config.config) + } + + /// Collects device data and sends it to MaxMind servers. + /// + /// On success, the completion handler receives an ``ObjCTrackingResult`` + /// containing the tracking token. On failure, it receives an `NSError`. + /// + /// - Parameter completion: Called on the main queue with the result or error. + @objc + public func collectAndSend(completion: @escaping (ObjCTrackingResult?, NSError?) -> Void) { + Task { @MainActor in + do { + let result = try await tracker.collectAndSend() + completion(ObjCTrackingResult(result: result), nil) + } catch { + completion(nil, error as NSError) + } + } + } + + /// Cancels automatic collection and releases resources. + @objc + public func shutdown() { + tracker.shutdown() + } +} diff --git a/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift b/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift new file mode 100644 index 0000000..9f2f512 --- /dev/null +++ b/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Objective-C compatible configuration for the MinFraud Device SDK. +/// +/// This class wraps ``SDKConfig`` for use from Objective-C code. +/// Swift callers should use ``SDKConfig`` directly. +@objc(MMSDKConfig) +public final class ObjCSDKConfig: NSObject { + let config: SDKConfig + + /// Creates a new SDK configuration with all options. + /// + /// - Parameters: + /// - accountID: Your MaxMind account ID. Must be positive. + /// - serverURL: Custom server URL, or `nil` to use default servers. + /// - loggingEnabled: Whether to enable logging. + /// - collectionIntervalSeconds: Automatic collection interval in seconds. + /// Must be `0` (disabled) or at least `300`. + @objc + public init( + accountID: Int, + serverURL: URL?, + loggingEnabled: Bool, + collectionIntervalSeconds: Int + ) { + self.config = SDKConfig( + accountID: accountID, + serverURL: serverURL, + loggingEnabled: loggingEnabled, + collectionIntervalSeconds: collectionIntervalSeconds + ) + } + + /// Creates a new SDK configuration with default options. + /// + /// - Parameter accountID: Your MaxMind account ID. Must be positive. + @objc + public convenience init(accountID: Int) { + self.init( + accountID: accountID, + serverURL: nil, + loggingEnabled: false, + collectionIntervalSeconds: 0 + ) + } +} diff --git a/Sources/MinFraudDevice/ObjC/MMTrackingResult.swift b/Sources/MinFraudDevice/ObjC/MMTrackingResult.swift new file mode 100644 index 0000000..1a62585 --- /dev/null +++ b/Sources/MinFraudDevice/ObjC/MMTrackingResult.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Objective-C compatible result of a device tracking operation. +/// +/// This class wraps ``TrackingResult`` for use from Objective-C code. +/// Swift callers should use ``TrackingResult`` directly. +@objc(MMTrackingResult) +public final class ObjCTrackingResult: NSObject { + /// Opaque tracking token to pass to the minFraud API. + @objc public let trackingToken: String + + init(result: TrackingResult) { + self.trackingToken = result.trackingToken + } + + public override var description: String { + "MMTrackingResult(trackingToken: )" + } +} diff --git a/Tests/MinFraudDeviceObjCTests/ObjCWrapperTests.m b/Tests/MinFraudDeviceObjCTests/ObjCWrapperTests.m new file mode 100644 index 0000000..b20eb33 --- /dev/null +++ b/Tests/MinFraudDeviceObjCTests/ObjCWrapperTests.m @@ -0,0 +1,44 @@ +@import XCTest; +@import MinFraudDevice; + +@interface ObjCWrapperTests : XCTestCase +@end + +@implementation ObjCWrapperTests + +- (void)testSDKConfigCreationWithDefaults { + MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:12345]; + XCTAssertNotNil(config); +} + +- (void)testSDKConfigCreationWithAllOptions { + NSURL *url = [NSURL URLWithString:@"https://custom.example.com"]; + MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:12345 + serverURL:url + loggingEnabled:YES + collectionIntervalSeconds:300]; + XCTAssertNotNil(config); +} + +- (void)testDeviceTrackerCreation { + MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:12345]; + MMDeviceTracker *tracker = [[MMDeviceTracker alloc] initWithConfig:config]; + XCTAssertNotNil(tracker); + [tracker shutdown]; +} + +- (void)testTrackingResultDescription { + // We can't easily construct an MMTrackingResult from ObjC (init is internal), + // but we can verify the class is visible and the API compiles. + XCTAssertTrue([MMTrackingResult instancesRespondToSelector:@selector(trackingToken)]); +} + +- (void)testCollectAndSendSignature { + // Verify the completion-handler API is callable from ObjC. + MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:12345]; + MMDeviceTracker *tracker = [[MMDeviceTracker alloc] initWithConfig:config]; + XCTAssertTrue([tracker respondsToSelector:@selector(collectAndSendWithCompletion:)]); + [tracker shutdown]; +} + +@end diff --git a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift new file mode 100644 index 0000000..c5a3dcb --- /dev/null +++ b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import MinFraudDevice + +final class ObjCWrapperTests: XCTestCase { + override func setUp() { + super.setUp() + URLProtocol.registerClass(MockURLProtocol.self) + } + + override func tearDown() { + URLProtocol.unregisterClass(MockURLProtocol.self) + MockURLProtocol.requestHandler = nil + super.tearDown() + } + + // MARK: - MMSDKConfig + + func testObjCSDKConfigDefaults() { + let config = ObjCSDKConfig(accountID: 12345) + XCTAssertEqual(config.config.accountID, 12345) + XCTAssertNil(config.config.serverURL) + XCTAssertFalse(config.config.loggingEnabled) + XCTAssertEqual(config.config.collectionIntervalSeconds, 0) + } + + func testObjCSDKConfigAllOptions() { + let url = URL(string: "https://custom.example.com")! + let config = ObjCSDKConfig( + accountID: 99999, + serverURL: url, + loggingEnabled: true, + collectionIntervalSeconds: 300 + ) + XCTAssertEqual(config.config.accountID, 99999) + XCTAssertEqual(config.config.serverURL, url) + XCTAssertTrue(config.config.loggingEnabled) + XCTAssertEqual(config.config.collectionIntervalSeconds, 300) + } + + // MARK: - MMDeviceTracker collectAndSend success + + func testObjCCollectAndSendSuccess() { + MockURLProtocol.requestHandler = { _ in + let data = Data("{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6}".utf8) + let response = HTTPURLResponse( + url: URL(string: "https://test.maxmind.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, data) + } + + let config = ObjCSDKConfig(accountID: 12345, serverURL: URL(string: "https://test.maxmind.com")!, + loggingEnabled: false, collectionIntervalSeconds: 0) + let tracker = ObjCDeviceTracker(config: config) + let expectation = expectation(description: "collectAndSend completes") + + tracker.collectAndSend { result, error in + XCTAssertNil(error) + XCTAssertNotNil(result) + XCTAssertEqual(result?.trackingToken, "abc123:hmac456") + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + tracker.shutdown() + } + + // MARK: - MMDeviceTracker collectAndSend failure + + func testObjCCollectAndSendServerError() { + MockURLProtocol.requestHandler = { _ in + let data = Data("{\"error\":\"Server Error\"}".utf8) + let response = HTTPURLResponse( + url: URL(string: "https://test.maxmind.com")!, + statusCode: 500, + httpVersion: nil, + headerFields: nil + )! + return (response, data) + } + + let config = ObjCSDKConfig(accountID: 12345, serverURL: URL(string: "https://test.maxmind.com")!, + loggingEnabled: false, collectionIntervalSeconds: 0) + let tracker = ObjCDeviceTracker(config: config) + let expectation = expectation(description: "collectAndSend fails") + + tracker.collectAndSend { result, error in + XCTAssertNil(result) + XCTAssertNotNil(error) + XCTAssertEqual(error?.domain, "\(SDKConfig.identifier).api") + XCTAssertEqual(error?.code, 500) + expectation.fulfill() + } + + waitForExpectations(timeout: 5) + tracker.shutdown() + } + + // MARK: - MMTrackingResult + + func testObjCTrackingResultRedactsDescription() { + let result = ObjCTrackingResult(result: TrackingResult(trackingToken: "secret")) + XCTAssertEqual(result.trackingToken, "secret") + XCTAssertFalse(result.description.contains("secret")) + } + + // MARK: - CustomNSError bridging + + func testMinFraudDeviceErrorBridgesToNSError() { + let error = MinFraudDeviceError.idfvUnavailable as NSError + XCTAssertEqual(error.domain, SDKConfig.identifier) + XCTAssertEqual(error.code, 1) + } + + func testAPIErrorBridgesToNSError() { + let error = APIError.serverError(statusCode: 403, message: "Forbidden") as NSError + XCTAssertEqual(error.domain, "\(SDKConfig.identifier).api") + XCTAssertEqual(error.code, 403) + } +} From 3c6d20a7ff490248f0b98a5036ad5726a2fbe09d Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Tue, 7 Apr 2026 16:43:30 -0400 Subject: [PATCH 09/31] Public API changes are compared against main only --- .github/workflows/api-compat.yml | 42 +- api-baseline.json | 1054 ------------------------------ 2 files changed, 34 insertions(+), 1062 deletions(-) delete mode 100644 api-baseline.json diff --git a/.github/workflows/api-compat.yml b/.github/workflows/api-compat.yml index aaae846..8c0639c 100644 --- a/.github/workflows/api-compat.yml +++ b/.github/workflows/api-compat.yml @@ -1,6 +1,6 @@ name: API Compatibility -on: [push, pull_request] +on: [pull_request] permissions: {} @@ -12,9 +12,10 @@ jobs: contents: read steps: - - name: Checkout code + - name: Checkout base branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + ref: ${{ github.event.pull_request.base.ref }} persist-credentials: false - name: Set up Xcode @@ -22,12 +23,37 @@ jobs: with: xcode-version: latest-stable - - name: Build + - name: Build base branch run: | xcodebuild build \ -scheme MinFraudDevice \ -destination 'generic/platform=iOS Simulator' \ - -derivedDataPath .build/api-compat \ + -derivedDataPath .build/api-compat-base \ + ONLY_ACTIVE_ARCH=NO \ + | xcpretty && exit ${PIPESTATUS[0]} + + - name: Generate baseline from base branch + run: | + xcrun swift-api-digester -dump-sdk \ + -module MinFraudDevice \ + -sdk "$(xcrun --show-sdk-path --sdk iphonesimulator)" \ + -target arm64-apple-ios15.0-simulator \ + -I .build/api-compat-base/Build/Products/Debug-iphonesimulator \ + -swift-only \ + -o .build/api-baseline.json + + - name: Checkout PR branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + clean: false + + - name: Build PR branch + run: | + xcodebuild build \ + -scheme MinFraudDevice \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath .build/api-compat-pr \ ONLY_ACTIVE_ARCH=NO \ | xcpretty && exit ${PIPESTATUS[0]} @@ -35,10 +61,10 @@ jobs: run: | DIAGS=$(xcrun swift-api-digester -diagnose-sdk \ -module MinFraudDevice \ - -baseline-path api-baseline.json \ + -baseline-path .build/api-baseline.json \ -sdk "$(xcrun --show-sdk-path --sdk iphonesimulator)" \ -target arm64-apple-ios15.0-simulator \ - -I .build/api-compat/Build/Products/Debug-iphonesimulator \ + -I .build/api-compat-pr/Build/Products/Debug-iphonesimulator \ -swift-only \ -compiler-style-diags 2>&1) @@ -46,8 +72,8 @@ jobs: echo "::error::API breaking changes detected:" echo "$DIAGS" | grep "API breakage:" echo "" - echo "If this change is intentional, update api-baseline.json in your PR by" - echo "regenerating it with xcrun swift-api-digester -dump-sdk." + echo "This check compares the public API of this PR against the base branch." + echo "If this change is intentional, no action is needed — this is an informational check." exit 1 fi diff --git a/api-baseline.json b/api-baseline.json deleted file mode 100644 index b337227..0000000 --- a/api-baseline.json +++ /dev/null @@ -1,1054 +0,0 @@ -{ - "ABIRoot": { - "kind": "Root", - "name": "MinFraudDevice", - "printedName": "MinFraudDevice", - "children": [ - { - "kind": "Import", - "name": "Foundation", - "printedName": "Foundation", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Import", - "name": "Foundation", - "printedName": "Foundation", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Import", - "name": "Foundation", - "printedName": "Foundation", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Import", - "name": "Foundation", - "printedName": "Foundation", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Import", - "name": "Security", - "printedName": "Security", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Import", - "name": "SwiftOnoneSupport", - "printedName": "SwiftOnoneSupport", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Import", - "name": "UIKit", - "printedName": "UIKit", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Import", - "name": "_Concurrency", - "printedName": "_Concurrency", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Import", - "name": "_StringProcessing", - "printedName": "_StringProcessing", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Import", - "name": "_SwiftConcurrencyShims", - "printedName": "_SwiftConcurrencyShims", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Import", - "name": "os", - "printedName": "os", - "declKind": "Import", - "moduleName": "MinFraudDevice" - }, - { - "kind": "TypeDecl", - "name": "SDKConfig", - "printedName": "SDKConfig", - "children": [ - { - "kind": "Var", - "name": "accountID", - "printedName": "accountID", - "children": [ - { - "kind": "TypeNominal", - "name": "Int", - "printedName": "Swift.Int", - "usr": "s:Si" - } - ], - "declKind": "Var", - "usr": "s:14MinFraudDevice9SDKConfigV9accountIDSivp", - "mangledName": "$s14MinFraudDevice9SDKConfigV9accountIDSivp", - "moduleName": "MinFraudDevice", - "declAttributes": [ - "HasStorage" - ], - "isLet": true, - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "Int", - "printedName": "Swift.Int", - "usr": "s:Si" - } - ], - "declKind": "Accessor", - "usr": "s:14MinFraudDevice9SDKConfigV9accountIDSivg", - "mangledName": "$s14MinFraudDevice9SDKConfigV9accountIDSivg", - "moduleName": "MinFraudDevice", - "implicit": true, - "declAttributes": [ - "Transparent" - ], - "accessorKind": "get" - } - ] - }, - { - "kind": "Var", - "name": "serverURL", - "printedName": "serverURL", - "children": [ - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "Foundation.URL?", - "children": [ - { - "kind": "TypeNominal", - "name": "URL", - "printedName": "Foundation.URL", - "usr": "s:10Foundation3URLV" - } - ], - "usr": "s:Sq" - } - ], - "declKind": "Var", - "usr": "s:14MinFraudDevice9SDKConfigV9serverURL10Foundation0F0VSgvp", - "mangledName": "$s14MinFraudDevice9SDKConfigV9serverURL10Foundation0F0VSgvp", - "moduleName": "MinFraudDevice", - "declAttributes": [ - "HasStorage" - ], - "isLet": true, - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "Foundation.URL?", - "children": [ - { - "kind": "TypeNominal", - "name": "URL", - "printedName": "Foundation.URL", - "usr": "s:10Foundation3URLV" - } - ], - "usr": "s:Sq" - } - ], - "declKind": "Accessor", - "usr": "s:14MinFraudDevice9SDKConfigV9serverURL10Foundation0F0VSgvg", - "mangledName": "$s14MinFraudDevice9SDKConfigV9serverURL10Foundation0F0VSgvg", - "moduleName": "MinFraudDevice", - "implicit": true, - "declAttributes": [ - "Transparent" - ], - "accessorKind": "get" - } - ] - }, - { - "kind": "Var", - "name": "loggingEnabled", - "printedName": "loggingEnabled", - "children": [ - { - "kind": "TypeNominal", - "name": "Bool", - "printedName": "Swift.Bool", - "usr": "s:Sb" - } - ], - "declKind": "Var", - "usr": "s:14MinFraudDevice9SDKConfigV14loggingEnabledSbvp", - "mangledName": "$s14MinFraudDevice9SDKConfigV14loggingEnabledSbvp", - "moduleName": "MinFraudDevice", - "declAttributes": [ - "HasStorage" - ], - "isLet": true, - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "Bool", - "printedName": "Swift.Bool", - "usr": "s:Sb" - } - ], - "declKind": "Accessor", - "usr": "s:14MinFraudDevice9SDKConfigV14loggingEnabledSbvg", - "mangledName": "$s14MinFraudDevice9SDKConfigV14loggingEnabledSbvg", - "moduleName": "MinFraudDevice", - "implicit": true, - "declAttributes": [ - "Transparent" - ], - "accessorKind": "get" - } - ] - }, - { - "kind": "Var", - "name": "collectionIntervalSeconds", - "printedName": "collectionIntervalSeconds", - "children": [ - { - "kind": "TypeNominal", - "name": "Int", - "printedName": "Swift.Int", - "usr": "s:Si" - } - ], - "declKind": "Var", - "usr": "s:14MinFraudDevice9SDKConfigV25collectionIntervalSecondsSivp", - "mangledName": "$s14MinFraudDevice9SDKConfigV25collectionIntervalSecondsSivp", - "moduleName": "MinFraudDevice", - "declAttributes": [ - "HasStorage" - ], - "isLet": true, - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "Int", - "printedName": "Swift.Int", - "usr": "s:Si" - } - ], - "declKind": "Accessor", - "usr": "s:14MinFraudDevice9SDKConfigV25collectionIntervalSecondsSivg", - "mangledName": "$s14MinFraudDevice9SDKConfigV25collectionIntervalSecondsSivg", - "moduleName": "MinFraudDevice", - "implicit": true, - "declAttributes": [ - "Transparent" - ], - "accessorKind": "get" - } - ] - }, - { - "kind": "Constructor", - "name": "init", - "printedName": "init(accountID:serverURL:loggingEnabled:collectionIntervalSeconds:)", - "children": [ - { - "kind": "TypeNominal", - "name": "SDKConfig", - "printedName": "MinFraudDevice.SDKConfig", - "usr": "s:14MinFraudDevice9SDKConfigV" - }, - { - "kind": "TypeNominal", - "name": "Int", - "printedName": "Swift.Int", - "usr": "s:Si" - }, - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "Foundation.URL?", - "children": [ - { - "kind": "TypeNominal", - "name": "URL", - "printedName": "Foundation.URL", - "usr": "s:10Foundation3URLV" - } - ], - "hasDefaultArg": true, - "usr": "s:Sq" - }, - { - "kind": "TypeNominal", - "name": "Bool", - "printedName": "Swift.Bool", - "hasDefaultArg": true, - "usr": "s:Sb" - }, - { - "kind": "TypeNominal", - "name": "Int", - "printedName": "Swift.Int", - "hasDefaultArg": true, - "usr": "s:Si" - } - ], - "declKind": "Constructor", - "usr": "s:14MinFraudDevice9SDKConfigV9accountID9serverURL14loggingEnabled25collectionIntervalSecondsACSi_10Foundation0H0VSgSbSitcfc", - "mangledName": "$s14MinFraudDevice9SDKConfigV9accountID9serverURL14loggingEnabled25collectionIntervalSecondsACSi_10Foundation0H0VSgSbSitcfc", - "moduleName": "MinFraudDevice", - "init_kind": "Designated" - } - ], - "declKind": "Struct", - "usr": "s:14MinFraudDevice9SDKConfigV", - "mangledName": "$s14MinFraudDevice9SDKConfigV", - "moduleName": "MinFraudDevice", - "conformances": [ - { - "kind": "Conformance", - "name": "Sendable", - "printedName": "Sendable", - "usr": "s:s8SendableP", - "mangledName": "$ss8SendableP" - }, - { - "kind": "Conformance", - "name": "SendableMetatype", - "printedName": "SendableMetatype", - "usr": "s:s16SendableMetatypeP", - "mangledName": "$ss16SendableMetatypeP" - }, - { - "kind": "Conformance", - "name": "Copyable", - "printedName": "Copyable", - "usr": "s:s8CopyableP", - "mangledName": "$ss8CopyableP" - }, - { - "kind": "Conformance", - "name": "Escapable", - "printedName": "Escapable", - "usr": "s:s9EscapableP", - "mangledName": "$ss9EscapableP" - } - ] - }, - { - "kind": "TypeDecl", - "name": "MinFraudDeviceError", - "printedName": "MinFraudDeviceError", - "children": [ - { - "kind": "Var", - "name": "idfvUnavailable", - "printedName": "idfvUnavailable", - "children": [ - { - "kind": "TypeFunc", - "name": "Function", - "printedName": "(MinFraudDevice.MinFraudDeviceError.Type) -> MinFraudDevice.MinFraudDeviceError", - "children": [ - { - "kind": "TypeNominal", - "name": "MinFraudDeviceError", - "printedName": "MinFraudDevice.MinFraudDeviceError", - "usr": "s:14MinFraudDevice0abC5ErrorO" - }, - { - "kind": "TypeNominal", - "name": "Metatype", - "printedName": "MinFraudDevice.MinFraudDeviceError.Type", - "children": [ - { - "kind": "TypeNominal", - "name": "MinFraudDeviceError", - "printedName": "MinFraudDevice.MinFraudDeviceError", - "usr": "s:14MinFraudDevice0abC5ErrorO" - } - ] - } - ] - } - ], - "declKind": "EnumElement", - "usr": "s:14MinFraudDevice0abC5ErrorO15idfvUnavailableyA2CmF", - "mangledName": "$s14MinFraudDevice0abC5ErrorO15idfvUnavailableyA2CmF", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Var", - "name": "errorDescription", - "printedName": "errorDescription", - "children": [ - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "Swift.String?", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "usr": "s:Sq" - } - ], - "declKind": "Var", - "usr": "s:14MinFraudDevice0abC5ErrorO16errorDescriptionSSSgvp", - "mangledName": "$s14MinFraudDevice0abC5ErrorO16errorDescriptionSSSgvp", - "moduleName": "MinFraudDevice", - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "Swift.String?", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "usr": "s:Sq" - } - ], - "declKind": "Accessor", - "usr": "s:14MinFraudDevice0abC5ErrorO16errorDescriptionSSSgvg", - "mangledName": "$s14MinFraudDevice0abC5ErrorO16errorDescriptionSSSgvg", - "moduleName": "MinFraudDevice", - "accessorKind": "get" - } - ] - }, - { - "kind": "Function", - "name": "__derived_enum_equals", - "printedName": "__derived_enum_equals(_:_:)", - "children": [ - { - "kind": "TypeNominal", - "name": "Bool", - "printedName": "Swift.Bool", - "usr": "s:Sb" - }, - { - "kind": "TypeNominal", - "name": "MinFraudDeviceError", - "printedName": "MinFraudDevice.MinFraudDeviceError", - "usr": "s:14MinFraudDevice0abC5ErrorO" - }, - { - "kind": "TypeNominal", - "name": "MinFraudDeviceError", - "printedName": "MinFraudDevice.MinFraudDeviceError", - "usr": "s:14MinFraudDevice0abC5ErrorO" - } - ], - "declKind": "Func", - "usr": "s:14MinFraudDevice0abC5ErrorO21__derived_enum_equalsySbAC_ACtFZ", - "mangledName": "$s14MinFraudDevice0abC5ErrorO21__derived_enum_equalsySbAC_ACtFZ", - "moduleName": "MinFraudDevice", - "static": true, - "implicit": true, - "declAttributes": [ - "Implements" - ], - "funcSelfKind": "NonMutating" - }, - { - "kind": "Function", - "name": "hash", - "printedName": "hash(into:)", - "children": [ - { - "kind": "TypeNominal", - "name": "Void", - "printedName": "()" - }, - { - "kind": "TypeNominal", - "name": "Hasher", - "printedName": "Swift.Hasher", - "paramValueOwnership": "InOut", - "usr": "s:s6HasherV" - } - ], - "declKind": "Func", - "usr": "s:14MinFraudDevice0abC5ErrorO4hash4intoys6HasherVz_tF", - "mangledName": "$s14MinFraudDevice0abC5ErrorO4hash4intoys6HasherVz_tF", - "moduleName": "MinFraudDevice", - "implicit": true, - "funcSelfKind": "NonMutating" - }, - { - "kind": "Var", - "name": "hashValue", - "printedName": "hashValue", - "children": [ - { - "kind": "TypeNominal", - "name": "Int", - "printedName": "Swift.Int", - "usr": "s:Si" - } - ], - "declKind": "Var", - "usr": "s:14MinFraudDevice0abC5ErrorO9hashValueSivp", - "mangledName": "$s14MinFraudDevice0abC5ErrorO9hashValueSivp", - "moduleName": "MinFraudDevice", - "implicit": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "Int", - "printedName": "Swift.Int", - "usr": "s:Si" - } - ], - "declKind": "Accessor", - "usr": "s:14MinFraudDevice0abC5ErrorO9hashValueSivg", - "mangledName": "$s14MinFraudDevice0abC5ErrorO9hashValueSivg", - "moduleName": "MinFraudDevice", - "implicit": true, - "accessorKind": "get" - } - ] - } - ], - "declKind": "Enum", - "usr": "s:14MinFraudDevice0abC5ErrorO", - "mangledName": "$s14MinFraudDevice0abC5ErrorO", - "moduleName": "MinFraudDevice", - "isEnumExhaustive": true, - "conformances": [ - { - "kind": "Conformance", - "name": "Hashable", - "printedName": "Hashable", - "usr": "s:SH", - "mangledName": "$sSH" - }, - { - "kind": "Conformance", - "name": "Error", - "printedName": "Error", - "usr": "s:s5ErrorP", - "mangledName": "$ss5ErrorP" - }, - { - "kind": "Conformance", - "name": "LocalizedError", - "printedName": "LocalizedError", - "usr": "s:10Foundation14LocalizedErrorP", - "mangledName": "$s10Foundation14LocalizedErrorP" - }, - { - "kind": "Conformance", - "name": "Equatable", - "printedName": "Equatable", - "usr": "s:SQ", - "mangledName": "$sSQ" - }, - { - "kind": "Conformance", - "name": "Sendable", - "printedName": "Sendable", - "usr": "s:s8SendableP", - "mangledName": "$ss8SendableP" - }, - { - "kind": "Conformance", - "name": "SendableMetatype", - "printedName": "SendableMetatype", - "usr": "s:s16SendableMetatypeP", - "mangledName": "$ss16SendableMetatypeP" - }, - { - "kind": "Conformance", - "name": "Copyable", - "printedName": "Copyable", - "usr": "s:s8CopyableP", - "mangledName": "$ss8CopyableP" - }, - { - "kind": "Conformance", - "name": "Escapable", - "printedName": "Escapable", - "usr": "s:s9EscapableP", - "mangledName": "$ss9EscapableP" - } - ] - }, - { - "kind": "TypeDecl", - "name": "DeviceTracker", - "printedName": "DeviceTracker", - "children": [ - { - "kind": "Constructor", - "name": "init", - "printedName": "init(config:)", - "children": [ - { - "kind": "TypeNominal", - "name": "DeviceTracker", - "printedName": "MinFraudDevice.DeviceTracker", - "usr": "s:14MinFraudDevice0C7TrackerC" - }, - { - "kind": "TypeNominal", - "name": "SDKConfig", - "printedName": "MinFraudDevice.SDKConfig", - "usr": "s:14MinFraudDevice9SDKConfigV" - } - ], - "declKind": "Constructor", - "usr": "s:14MinFraudDevice0C7TrackerC6configAcA9SDKConfigV_tcfc", - "mangledName": "$s14MinFraudDevice0C7TrackerC6configAcA9SDKConfigV_tcfc", - "moduleName": "MinFraudDevice", - "init_kind": "Designated" - }, - { - "kind": "Function", - "name": "collectAndSend", - "printedName": "collectAndSend()", - "children": [ - { - "kind": "TypeNominal", - "name": "TrackingResult", - "printedName": "MinFraudDevice.TrackingResult", - "usr": "s:14MinFraudDevice14TrackingResultV" - } - ], - "declKind": "Func", - "usr": "s:14MinFraudDevice0C7TrackerC14collectAndSendAA14TrackingResultVyYaKF", - "mangledName": "$s14MinFraudDevice0C7TrackerC14collectAndSendAA14TrackingResultVyYaKF", - "moduleName": "MinFraudDevice", - "declAttributes": [ - "Final" - ], - "throwing": true, - "funcSelfKind": "NonMutating" - }, - { - "kind": "Function", - "name": "shutdown", - "printedName": "shutdown()", - "children": [ - { - "kind": "TypeNominal", - "name": "Void", - "printedName": "()" - } - ], - "declKind": "Func", - "usr": "s:14MinFraudDevice0C7TrackerC8shutdownyyF", - "mangledName": "$s14MinFraudDevice0C7TrackerC8shutdownyyF", - "moduleName": "MinFraudDevice", - "declAttributes": [ - "Final" - ], - "funcSelfKind": "NonMutating" - } - ], - "declKind": "Class", - "usr": "s:14MinFraudDevice0C7TrackerC", - "mangledName": "$s14MinFraudDevice0C7TrackerC", - "moduleName": "MinFraudDevice", - "declAttributes": [ - "Final" - ], - "hasMissingDesignatedInitializers": true, - "conformances": [ - { - "kind": "Conformance", - "name": "Sendable", - "printedName": "Sendable", - "usr": "s:s8SendableP", - "mangledName": "$ss8SendableP" - }, - { - "kind": "Conformance", - "name": "SendableMetatype", - "printedName": "SendableMetatype", - "usr": "s:s16SendableMetatypeP", - "mangledName": "$ss16SendableMetatypeP" - }, - { - "kind": "Conformance", - "name": "Copyable", - "printedName": "Copyable", - "usr": "s:s8CopyableP", - "mangledName": "$ss8CopyableP" - }, - { - "kind": "Conformance", - "name": "Escapable", - "printedName": "Escapable", - "usr": "s:s9EscapableP", - "mangledName": "$ss9EscapableP" - } - ] - }, - { - "kind": "TypeDecl", - "name": "TrackingResult", - "printedName": "TrackingResult", - "children": [ - { - "kind": "Var", - "name": "trackingToken", - "printedName": "trackingToken", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Var", - "usr": "s:14MinFraudDevice14TrackingResultV13trackingTokenSSvp", - "mangledName": "$s14MinFraudDevice14TrackingResultV13trackingTokenSSvp", - "moduleName": "MinFraudDevice", - "declAttributes": [ - "HasStorage" - ], - "isLet": true, - "hasStorage": true, - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Accessor", - "usr": "s:14MinFraudDevice14TrackingResultV13trackingTokenSSvg", - "mangledName": "$s14MinFraudDevice14TrackingResultV13trackingTokenSSvg", - "moduleName": "MinFraudDevice", - "implicit": true, - "declAttributes": [ - "Transparent" - ], - "accessorKind": "get" - } - ] - }, - { - "kind": "Var", - "name": "description", - "printedName": "description", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Var", - "usr": "s:14MinFraudDevice14TrackingResultV11descriptionSSvp", - "mangledName": "$s14MinFraudDevice14TrackingResultV11descriptionSSvp", - "moduleName": "MinFraudDevice", - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "declKind": "Accessor", - "usr": "s:14MinFraudDevice14TrackingResultV11descriptionSSvg", - "mangledName": "$s14MinFraudDevice14TrackingResultV11descriptionSSvg", - "moduleName": "MinFraudDevice", - "accessorKind": "get" - } - ] - } - ], - "declKind": "Struct", - "usr": "s:14MinFraudDevice14TrackingResultV", - "mangledName": "$s14MinFraudDevice14TrackingResultV", - "moduleName": "MinFraudDevice", - "conformances": [ - { - "kind": "Conformance", - "name": "Sendable", - "printedName": "Sendable", - "usr": "s:s8SendableP", - "mangledName": "$ss8SendableP" - }, - { - "kind": "Conformance", - "name": "CustomStringConvertible", - "printedName": "CustomStringConvertible", - "usr": "s:s23CustomStringConvertibleP", - "mangledName": "$ss23CustomStringConvertibleP" - }, - { - "kind": "Conformance", - "name": "SendableMetatype", - "printedName": "SendableMetatype", - "usr": "s:s16SendableMetatypeP", - "mangledName": "$ss16SendableMetatypeP" - }, - { - "kind": "Conformance", - "name": "Copyable", - "printedName": "Copyable", - "usr": "s:s8CopyableP", - "mangledName": "$ss8CopyableP" - }, - { - "kind": "Conformance", - "name": "Escapable", - "printedName": "Escapable", - "usr": "s:s9EscapableP", - "mangledName": "$ss9EscapableP" - } - ] - }, - { - "kind": "TypeDecl", - "name": "APIError", - "printedName": "APIError", - "children": [ - { - "kind": "Var", - "name": "serverError", - "printedName": "serverError", - "children": [ - { - "kind": "TypeFunc", - "name": "Function", - "printedName": "(MinFraudDevice.APIError.Type) -> (Swift.Int, Swift.String) -> MinFraudDevice.APIError", - "children": [ - { - "kind": "TypeFunc", - "name": "Function", - "printedName": "(Swift.Int, Swift.String) -> MinFraudDevice.APIError", - "children": [ - { - "kind": "TypeNominal", - "name": "APIError", - "printedName": "MinFraudDevice.APIError", - "usr": "s:14MinFraudDevice8APIErrorO" - }, - { - "kind": "TypeNominal", - "name": "Tuple", - "printedName": "(statusCode: Swift.Int, message: Swift.String)", - "children": [ - { - "kind": "TypeNominal", - "name": "Int", - "printedName": "Swift.Int", - "usr": "s:Si" - }, - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ] - } - ] - }, - { - "kind": "TypeNominal", - "name": "Metatype", - "printedName": "MinFraudDevice.APIError.Type", - "children": [ - { - "kind": "TypeNominal", - "name": "APIError", - "printedName": "MinFraudDevice.APIError", - "usr": "s:14MinFraudDevice8APIErrorO" - } - ] - } - ] - } - ], - "declKind": "EnumElement", - "usr": "s:14MinFraudDevice8APIErrorO11serverErroryACSi_SStcACmF", - "mangledName": "$s14MinFraudDevice8APIErrorO11serverErroryACSi_SStcACmF", - "moduleName": "MinFraudDevice" - }, - { - "kind": "Var", - "name": "errorDescription", - "printedName": "errorDescription", - "children": [ - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "Swift.String?", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "usr": "s:Sq" - } - ], - "declKind": "Var", - "usr": "s:14MinFraudDevice8APIErrorO16errorDescriptionSSSgvp", - "mangledName": "$s14MinFraudDevice8APIErrorO16errorDescriptionSSSgvp", - "moduleName": "MinFraudDevice", - "accessors": [ - { - "kind": "Accessor", - "name": "Get", - "printedName": "Get()", - "children": [ - { - "kind": "TypeNominal", - "name": "Optional", - "printedName": "Swift.String?", - "children": [ - { - "kind": "TypeNominal", - "name": "String", - "printedName": "Swift.String", - "usr": "s:SS" - } - ], - "usr": "s:Sq" - } - ], - "declKind": "Accessor", - "usr": "s:14MinFraudDevice8APIErrorO16errorDescriptionSSSgvg", - "mangledName": "$s14MinFraudDevice8APIErrorO16errorDescriptionSSSgvg", - "moduleName": "MinFraudDevice", - "accessorKind": "get" - } - ] - } - ], - "declKind": "Enum", - "usr": "s:14MinFraudDevice8APIErrorO", - "mangledName": "$s14MinFraudDevice8APIErrorO", - "moduleName": "MinFraudDevice", - "isEnumExhaustive": true, - "conformances": [ - { - "kind": "Conformance", - "name": "Error", - "printedName": "Error", - "usr": "s:s5ErrorP", - "mangledName": "$ss5ErrorP" - }, - { - "kind": "Conformance", - "name": "LocalizedError", - "printedName": "LocalizedError", - "usr": "s:10Foundation14LocalizedErrorP", - "mangledName": "$s10Foundation14LocalizedErrorP" - }, - { - "kind": "Conformance", - "name": "Sendable", - "printedName": "Sendable", - "usr": "s:s8SendableP", - "mangledName": "$ss8SendableP" - }, - { - "kind": "Conformance", - "name": "SendableMetatype", - "printedName": "SendableMetatype", - "usr": "s:s16SendableMetatypeP", - "mangledName": "$ss16SendableMetatypeP" - }, - { - "kind": "Conformance", - "name": "Copyable", - "printedName": "Copyable", - "usr": "s:s8CopyableP", - "mangledName": "$ss8CopyableP" - }, - { - "kind": "Conformance", - "name": "Escapable", - "printedName": "Escapable", - "usr": "s:s9EscapableP", - "mangledName": "$ss9EscapableP" - } - ] - } - ], - "json_format_version": 8 - } -} \ No newline at end of file From b6eb9f6f538c001124c720ca8d47a1309f7ee382 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Tue, 7 Apr 2026 16:58:44 -0400 Subject: [PATCH 10/31] Support natural Obj-C behavior for failed MMSDKConfig init --- README.md | 4 ++ Sources/MinFraudDevice/ObjC/MMSDKConfig.swift | 14 +++++- .../ObjCWrapperTests.swift | 46 +++++++++++++++---- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c1cef40..edb1c68 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,10 @@ The SDK provides Objective-C compatible wrapper classes with an `MM` prefix. @import MinFraudDevice; MMSDKConfig *config = [[MMSDKConfig alloc] initWithAccountID:123456]; +if (!config) { + // Handle invalid configuration + return; +} MMDeviceTracker *tracker = [[MMDeviceTracker alloc] initWithConfig:config]; [tracker collectAndSendWithCompletion:^(MMTrackingResult *result, NSError *error) { diff --git a/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift b/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift index 9f2f512..e2c0bc0 100644 --- a/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift +++ b/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift @@ -10,6 +10,9 @@ public final class ObjCSDKConfig: NSObject { /// Creates a new SDK configuration with all options. /// + /// Returns `nil` if `accountID` is not positive or + /// `collectionIntervalSeconds` is not `0` or at least `300`. + /// /// - Parameters: /// - accountID: Your MaxMind account ID. Must be positive. /// - serverURL: Custom server URL, or `nil` to use default servers. @@ -17,12 +20,17 @@ public final class ObjCSDKConfig: NSObject { /// - collectionIntervalSeconds: Automatic collection interval in seconds. /// Must be `0` (disabled) or at least `300`. @objc - public init( + public init?( accountID: Int, serverURL: URL?, loggingEnabled: Bool, collectionIntervalSeconds: Int ) { + guard accountID > 0, + collectionIntervalSeconds == 0 || collectionIntervalSeconds >= 300 + else { + return nil + } self.config = SDKConfig( accountID: accountID, serverURL: serverURL, @@ -33,9 +41,11 @@ public final class ObjCSDKConfig: NSObject { /// Creates a new SDK configuration with default options. /// + /// Returns `nil` if `accountID` is not positive. + /// /// - Parameter accountID: Your MaxMind account ID. Must be positive. @objc - public convenience init(accountID: Int) { + public convenience init?(accountID: Int) { self.init( accountID: accountID, serverURL: nil, diff --git a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift index c5a3dcb..68c8b82 100644 --- a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift +++ b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift @@ -17,10 +17,11 @@ final class ObjCWrapperTests: XCTestCase { func testObjCSDKConfigDefaults() { let config = ObjCSDKConfig(accountID: 12345) - XCTAssertEqual(config.config.accountID, 12345) - XCTAssertNil(config.config.serverURL) - XCTAssertFalse(config.config.loggingEnabled) - XCTAssertEqual(config.config.collectionIntervalSeconds, 0) + XCTAssertNotNil(config) + XCTAssertEqual(config?.config.accountID, 12345) + XCTAssertNil(config?.config.serverURL) + XCTAssertFalse(config!.config.loggingEnabled) + XCTAssertEqual(config?.config.collectionIntervalSeconds, 0) } func testObjCSDKConfigAllOptions() { @@ -31,10 +32,35 @@ final class ObjCWrapperTests: XCTestCase { loggingEnabled: true, collectionIntervalSeconds: 300 ) - XCTAssertEqual(config.config.accountID, 99999) - XCTAssertEqual(config.config.serverURL, url) - XCTAssertTrue(config.config.loggingEnabled) - XCTAssertEqual(config.config.collectionIntervalSeconds, 300) + XCTAssertNotNil(config) + XCTAssertEqual(config?.config.accountID, 99999) + XCTAssertEqual(config?.config.serverURL, url) + XCTAssertTrue(config!.config.loggingEnabled) + XCTAssertEqual(config?.config.collectionIntervalSeconds, 300) + } + + func testObjCSDKConfigReturnsNilForInvalidAccountID() { + XCTAssertNil(ObjCSDKConfig(accountID: 0)) + XCTAssertNil(ObjCSDKConfig(accountID: -1)) + } + + func testObjCSDKConfigReturnsNilForInvalidCollectionInterval() { + XCTAssertNil( + ObjCSDKConfig( + accountID: 12345, + serverURL: nil, + loggingEnabled: false, + collectionIntervalSeconds: 60 + ) + ) + XCTAssertNil( + ObjCSDKConfig( + accountID: 12345, + serverURL: nil, + loggingEnabled: false, + collectionIntervalSeconds: 299 + ) + ) } // MARK: - MMDeviceTracker collectAndSend success @@ -52,7 +78,7 @@ final class ObjCWrapperTests: XCTestCase { } let config = ObjCSDKConfig(accountID: 12345, serverURL: URL(string: "https://test.maxmind.com")!, - loggingEnabled: false, collectionIntervalSeconds: 0) + loggingEnabled: false, collectionIntervalSeconds: 0)! let tracker = ObjCDeviceTracker(config: config) let expectation = expectation(description: "collectAndSend completes") @@ -82,7 +108,7 @@ final class ObjCWrapperTests: XCTestCase { } let config = ObjCSDKConfig(accountID: 12345, serverURL: URL(string: "https://test.maxmind.com")!, - loggingEnabled: false, collectionIntervalSeconds: 0) + loggingEnabled: false, collectionIntervalSeconds: 0)! let tracker = ObjCDeviceTracker(config: config) let expectation = expectation(description: "collectAndSend fails") From 44e07f5f4945182d571fbbc27218d10425f99d94 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Tue, 7 Apr 2026 17:05:56 -0400 Subject: [PATCH 11/31] Fix name of returned type --- Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift index 952050f..83fec6f 100644 --- a/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift +++ b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift @@ -18,7 +18,7 @@ public final class ObjCDeviceTracker: NSObject { /// Collects device data and sends it to MaxMind servers. /// - /// On success, the completion handler receives an ``ObjCTrackingResult`` + /// On success, the completion handler receives an ``MMTrackingResult`` /// containing the tracking token. On failure, it receives an `NSError`. /// /// - Parameter completion: Called on the main queue with the result or error. From b4cf3131b06435164408fbdfa429e55dd665cb48 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Tue, 7 Apr 2026 17:28:48 -0400 Subject: [PATCH 12/31] Prevent cancelled tasks from calling the completion handler in Obj-C wrapper --- .../MinFraudDevice/ObjC/MMDeviceTracker.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift index 83fec6f..253f182 100644 --- a/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift +++ b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift @@ -7,6 +7,8 @@ import Foundation @objc(MMDeviceTracker) public final class ObjCDeviceTracker: NSObject { private let tracker: DeviceTracker + private var isShutDown = false + private let lock = NSLock() /// Creates a new device tracker with the given configuration. /// @@ -20,23 +22,38 @@ public final class ObjCDeviceTracker: NSObject { /// /// On success, the completion handler receives an ``MMTrackingResult`` /// containing the tracking token. On failure, it receives an `NSError`. + /// The completion handler is not called if ``shutdown`` has been called + /// before the operation completes. /// /// - Parameter completion: Called on the main queue with the result or error. @objc public func collectAndSend(completion: @escaping (ObjCTrackingResult?, NSError?) -> Void) { - Task { @MainActor in + Task { @MainActor [weak self] in + guard let self else { return } do { let result = try await tracker.collectAndSend() + self.lock.lock() + defer { self.lock.unlock() } + guard !self.isShutDown else { return } completion(ObjCTrackingResult(result: result), nil) } catch { + self.lock.lock() + defer { self.lock.unlock() } + guard !self.isShutDown else { return } completion(nil, error as NSError) } } } /// Cancels automatic collection and releases resources. + /// + /// Any in-flight ``collectAndSend`` call will complete silently + /// without invoking its completion handler. @objc public func shutdown() { + lock.lock() + defer { lock.unlock() } + isShutDown = true tracker.shutdown() } } From 5466a6e5d2b2408c7e75c93d1744e4752d5f16e3 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Tue, 7 Apr 2026 17:53:53 -0400 Subject: [PATCH 13/31] Support better error source signals in Obj-C wrapper --- CLAUDE.md | 2 +- .../Network/DeviceAPIClient.swift | 17 +++++++++++++++-- .../DeviceAPIClientTests.swift | 6 +++--- .../MinFraudDeviceTests/ObjCWrapperTests.swift | 9 ++++++++- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a947610..2f65d7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,4 +150,4 @@ disabled, `logger` is `nil` and all `logger?.method()` calls are no-ops. ## Error Types - `MinFraudDeviceError` (public) — `idfvUnavailable` -- `APIError` (public) — `serverError(statusCode:message:)` +- `APIError` (public) — `serverError(statusCode:message:)`, `responseDecodingFailed(String)` diff --git a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift index 649a301..8b5217c 100644 --- a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift +++ b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift @@ -21,10 +21,16 @@ public enum APIError: Error, LocalizedError { /// The server returned a non-success HTTP status code. case serverError(statusCode: Int, message: String) + /// The server returned a success status code but the response body + /// could not be decoded. + case responseDecodingFailed(String) + public var errorDescription: String? { switch self { case .serverError(let statusCode, let message): return "Server returned \(statusCode): \(message)" + case .responseDecodingFailed(let detail): + return "Failed to decode server response: \(detail)" } } } @@ -35,6 +41,7 @@ extension APIError: CustomNSError { public var errorCode: Int { switch self { case .serverError(let statusCode, _): return statusCode + case .responseDecodingFailed: return -1 } } @@ -42,6 +49,8 @@ extension APIError: CustomNSError { switch self { case .serverError(_, let message): return [NSLocalizedDescriptionKey: errorDescription ?? message] + case .responseDecodingFailed: + return [NSLocalizedDescriptionKey: errorDescription ?? "Unknown decoding error"] } } } @@ -114,7 +123,11 @@ final class DeviceAPIClient: Sendable { throw APIError.serverError(statusCode: httpResponse.statusCode, message: message) } - let decoder = JSONDecoder() - return try decoder.decode(ServerResponse.self, from: data) + do { + let decoder = JSONDecoder() + return try decoder.decode(ServerResponse.self, from: data) + } catch { + throw APIError.responseDecodingFailed(error.localizedDescription) + } } } diff --git a/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift b/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift index 9dae97a..7a5178c 100644 --- a/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift @@ -77,10 +77,10 @@ final class DeviceAPIClientTests: XCTestCase { do { _ = try await client.sendDeviceData(testDeviceData) XCTFail("Expected error for case: \(tc.label)") - } catch is DecodingError { - // Expected + } catch is APIError { + // Expected: APIError.responseDecodingFailed } catch { - XCTFail("Expected DecodingError for case \(tc.label), got: \(error)") + XCTFail("Expected APIError.responseDecodingFailed for case \(tc.label), got: \(error)") } } } diff --git a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift index 68c8b82..572239e 100644 --- a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift +++ b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift @@ -140,9 +140,16 @@ final class ObjCWrapperTests: XCTestCase { XCTAssertEqual(error.code, 1) } - func testAPIErrorBridgesToNSError() { + func testAPIErrorServerErrorBridgesToNSError() { let error = APIError.serverError(statusCode: 403, message: "Forbidden") as NSError XCTAssertEqual(error.domain, "\(SDKConfig.identifier).api") XCTAssertEqual(error.code, 403) } + + func testAPIErrorResponseDecodingFailedBridgesToNSError() { + let error = APIError.responseDecodingFailed("missing field") as NSError + XCTAssertEqual(error.domain, "\(SDKConfig.identifier).api") + XCTAssertEqual(error.code, -1) + XCTAssertTrue(error.localizedDescription.contains("missing field")) + } } From b10547e94cd7762794f0d2d9f0bcd84d48d737cb Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Tue, 7 Apr 2026 17:56:00 -0400 Subject: [PATCH 14/31] Fix-up docs now that DecodingError is dissolved --- Sources/MinFraudDevice/DeviceTracker.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/MinFraudDevice/DeviceTracker.swift b/Sources/MinFraudDevice/DeviceTracker.swift index 0efa1b7..f2663db 100644 --- a/Sources/MinFraudDevice/DeviceTracker.swift +++ b/Sources/MinFraudDevice/DeviceTracker.swift @@ -92,7 +92,8 @@ public final class DeviceTracker: @unchecked Sendable { /// - Returns: A ``TrackingResult`` containing the tracking token. /// - Throws: ``MinFraudDeviceError/idfvUnavailable`` if the device /// identifier cannot be obtained, or an ``APIError`` - /// if the network request fails. + /// if the server returns a non-success status code or the response + /// body cannot be decoded. public func collectAndSend() async throws -> TrackingResult { let deviceData = try collector.collect() let response = try await apiClient.sendDeviceData(deviceData) From b247f95c4056de93bb8c4e5d8225914182580631 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Tue, 7 Apr 2026 17:58:02 -0400 Subject: [PATCH 15/31] Fix up dual-IP approach description for Claude --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f65d7a..3bb84e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,7 @@ To capture both IP addresses for a device: 2. If response contains `ip_version: 6`, POST to `d-ipv4.mmapiws.com/device/ios` with request duration 3. IPv4 failure is non-fatal (logged, not propagated) -4. Stored ID from IPv6 response is persisted and returned as a tracking token +4. Stored ID from IPv6 response is returned to the caller; `DeviceTracker` persists it in the keychain and returns it as a tracking token If a custom server URL is configured, dual-request is disabled. From 8efbb3f298878117a7bece5b2d885f7c64703136 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Tue, 7 Apr 2026 18:01:31 -0400 Subject: [PATCH 16/31] Ensure we get the expected stored ID --- Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift b/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift index 7a5178c..be50e49 100644 --- a/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift @@ -167,11 +167,11 @@ final class DeviceAPIClientTests: XCTestCase { // MARK: - Dual Request Tests private var validIPv6Response: String { - "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":6}" + "{\"stored_id\":\"ipv6-stored-id\",\"ip_version\":6}" } private var validIPv4Response: String { - "{\"stored_id\":\"abc123:hmac456\",\"ip_version\":4}" + "{\"stored_id\":\"ipv4-stored-id\",\"ip_version\":4}" } func testDualRequestSendsToIPv6AndIPv4Endpoints() async throws { @@ -191,11 +191,12 @@ final class DeviceAPIClientTests: XCTestCase { } let client = makeClient(serverURL: nil) - _ = try await client.sendDeviceData(testDeviceData) + let result = try await client.sendDeviceData(testDeviceData) XCTAssertEqual(capturedURLs.count, 2) XCTAssertTrue(capturedURLs[0].contains("d-ipv6.mmapiws.com")) XCTAssertTrue(capturedURLs[1].contains("d-ipv4.mmapiws.com")) + XCTAssertEqual(result.storedID, "ipv6-stored-id") } func testDualRequestIncludesRequestDurationOnIPv4Only() async throws { @@ -276,7 +277,7 @@ final class DeviceAPIClientTests: XCTestCase { let client = makeClient(serverURL: nil) let response = try await client.sendDeviceData(testDeviceData) - XCTAssertEqual(response.storedID, "abc123:hmac456") + XCTAssertEqual(response.storedID, "ipv6-stored-id") XCTAssertEqual(requestCount, 2) } } From 72cda92e246a88bc6b3add6e61a64775ea3bf9bf Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Tue, 7 Apr 2026 22:37:13 -0400 Subject: [PATCH 17/31] Complete MinFraudDeviceError' conformance to CustomNSError --- Sources/MinFraudDevice/DeviceTracker.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/MinFraudDevice/DeviceTracker.swift b/Sources/MinFraudDevice/DeviceTracker.swift index f2663db..c36cfc7 100644 --- a/Sources/MinFraudDevice/DeviceTracker.swift +++ b/Sources/MinFraudDevice/DeviceTracker.swift @@ -22,6 +22,10 @@ extension MinFraudDeviceError: CustomNSError { case .idfvUnavailable: return 1 } } + + public var errorUserInfo: [String: Any] { + [NSLocalizedDescriptionKey: errorDescription ?? "Unknown error"] + } } /// Main entry point for the MinFraud Device SDK. From 2cbdff6f8ca18c7ece469df753312504389740cf Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 11:03:33 -0400 Subject: [PATCH 18/31] Facilitate access to detailed error information --- Sources/MinFraudDevice/Network/DeviceAPIClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift index 8b5217c..a681d9c 100644 --- a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift +++ b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift @@ -127,7 +127,7 @@ final class DeviceAPIClient: Sendable { let decoder = JSONDecoder() return try decoder.decode(ServerResponse.self, from: data) } catch { - throw APIError.responseDecodingFailed(error.localizedDescription) + throw APIError.responseDecodingFailed(String(describing: error)) } } } From 56c4cec0c06a4edc58c2617f7d6a027e7f332871 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 11:24:35 -0400 Subject: [PATCH 19/31] Make sure the completion handler is called exactly once unless specifically shutdown --- Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift index 253f182..9947efe 100644 --- a/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift +++ b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift @@ -22,16 +22,18 @@ public final class ObjCDeviceTracker: NSObject { /// /// On success, the completion handler receives an ``MMTrackingResult`` /// containing the tracking token. On failure, it receives an `NSError`. - /// The completion handler is not called if ``shutdown`` has been called - /// before the operation completes. + /// + /// The completion handler is always called exactly once unless + /// ``shutdown`` is called before the operation completes. The tracker + /// is kept alive until the operation finishes, even if all other + /// references are released. /// /// - Parameter completion: Called on the main queue with the result or error. @objc public func collectAndSend(completion: @escaping (ObjCTrackingResult?, NSError?) -> Void) { - Task { @MainActor [weak self] in - guard let self else { return } + Task { @MainActor in do { - let result = try await tracker.collectAndSend() + let result = try await self.tracker.collectAndSend() self.lock.lock() defer { self.lock.unlock() } guard !self.isShutDown else { return } From 398237751787963bbf5cfde921d699318216f561 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 11:30:12 -0400 Subject: [PATCH 20/31] Clarify the advantage of a monotonic clock for duration purposes --- Sources/MinFraudDevice/Network/DeviceAPIClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift index a681d9c..d3d5b58 100644 --- a/Sources/MinFraudDevice/Network/DeviceAPIClient.swift +++ b/Sources/MinFraudDevice/Network/DeviceAPIClient.swift @@ -78,7 +78,7 @@ final class DeviceAPIClient: Sendable { private func sendWithDualRequest(_ deviceData: DeviceData) async throws -> ServerResponse { let ipv6URL = URL(string: "https://\(SDKConfig.defaultIPv6Host)\(SDKConfig.endpointPath)")! - // Use a monotonic approach for calculating request duration. + // ProcessInfo.systemUptime uses a monotonic clock, and is therefore immune to wall-clock drift from NTP adjustments. let startTime = ProcessInfo.processInfo.systemUptime let ipv6Response = try await sendToURL(deviceData, url: ipv6URL) let requestDurationMS = Int((ProcessInfo.processInfo.systemUptime - startTime) * 1000) From 4655aade19c7756475f8b635e0ccf9004792e397 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 11:32:20 -0400 Subject: [PATCH 21/31] Document additional response possibility in CLAUDE.md --- CLAUDE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3bb84e7..15e5b0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,7 @@ subclasses and completion-handler APIs. - URLSession-based HTTP client - Dual-stack IPv6/IPv4 flow (see below) - Throws `APIError.serverError` on non-success responses + - Throws `APIError.responseDecodingFailed` on unparseable success responses ### Dual-Request Flow (IPv6/IPv4) @@ -119,7 +120,8 @@ To capture both IP addresses for a device: 2. If response contains `ip_version: 6`, POST to `d-ipv4.mmapiws.com/device/ios` with request duration 3. IPv4 failure is non-fatal (logged, not propagated) -4. Stored ID from IPv6 response is returned to the caller; `DeviceTracker` persists it in the keychain and returns it as a tracking token +4. Stored ID from IPv6 response is returned to the caller; `DeviceTracker` + persists it in the keychain and returns it as a tracking token If a custom server URL is configured, dual-request is disabled. @@ -150,4 +152,5 @@ disabled, `logger` is `nil` and all `logger?.method()` calls are no-ops. ## Error Types - `MinFraudDeviceError` (public) — `idfvUnavailable` -- `APIError` (public) — `serverError(statusCode:message:)`, `responseDecodingFailed(String)` +- `APIError` (public) — `serverError(statusCode:message:)`, + `responseDecodingFailed(String)` From c1660c7dbddae8a5c3b2af8510f325e32a0a3c37 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 11:38:05 -0400 Subject: [PATCH 22/31] Add a missing test case --- Tests/MinFraudDeviceTests/ObjCWrapperTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift index 572239e..5e4a526 100644 --- a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift +++ b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift @@ -138,6 +138,7 @@ final class ObjCWrapperTests: XCTestCase { let error = MinFraudDeviceError.idfvUnavailable as NSError XCTAssertEqual(error.domain, SDKConfig.identifier) XCTAssertEqual(error.code, 1) + XCTAssertTrue(error.localizedDescription.contains("IDFV")) } func testAPIErrorServerErrorBridgesToNSError() { From 67d2ef7168f6fe32c55da91b8645cf671a619fba Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 11:54:40 -0400 Subject: [PATCH 23/31] Correct the Xcode version required --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index edb1c68..d0af9f4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ iOS SDK for collecting and reporting device data to MaxMind. - iOS 15.0+ - Swift 5.9+ -- Xcode 14.3+ +- Xcode 15.0+ ## Installation From f8be567c409fd621e8777154ff3aa3ae7a072c59 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 11:58:21 -0400 Subject: [PATCH 24/31] Fix awkward sentence --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d0af9f4..f00572e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ let tracker = DeviceTracker(config: config) ### 2. Collect and Send Device Data -And, use the tracking token in your calls to the minFraud API. +Use the tracking token in your calls to the minFraud API. ```swift do { From 3f088b5cf929acbf5194fb178ebcc95fc0707666 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 12:05:18 -0400 Subject: [PATCH 25/31] Fail the test if the received value is not an Int --- Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift b/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift index be50e49..b9bb386 100644 --- a/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceAPIClientTests.swift @@ -223,10 +223,8 @@ final class DeviceAPIClientTests: XCTestCase { XCTAssertEqual(capturedBodies.count, 2) XCTAssertNil(capturedBodies[0]["request_duration"]) - XCTAssertNotNil(capturedBodies[1]["request_duration"]) - if let duration = capturedBodies[1]["request_duration"] as? Int { - XCTAssertGreaterThanOrEqual(duration, 0) - } + let duration = try XCTUnwrap(capturedBodies[1]["request_duration"] as? Int) + XCTAssertGreaterThanOrEqual(duration, 0) } func testDualRequestSkipsIPv4WhenIPVersionIsNot6() async throws { From 39bdad5e6140823832ca00d5b6b35c7dfbecd55d Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 12:11:26 -0400 Subject: [PATCH 26/31] Test the promise that calling shutdown will block the completion handler call --- .../ObjCWrapperTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift index 5e4a526..aa7070d 100644 --- a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift +++ b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift @@ -124,6 +124,33 @@ final class ObjCWrapperTests: XCTestCase { tracker.shutdown() } + // MARK: - MMDeviceTracker shutdown suppresses callback + + func testObjCShutdownSuppressesCompletionHandler() { + // Use a handler that blocks, keeping the request in-flight. + MockURLProtocol.requestHandler = { _ in + Thread.sleep(forTimeInterval: 5) + throw URLError(.cancelled) + } + + let config = ObjCSDKConfig(accountID: 12345, serverURL: URL(string: "https://test.maxmind.com")!, + loggingEnabled: false, collectionIntervalSeconds: 0)! + let tracker = ObjCDeviceTracker(config: config) + + var completionCalled = false + tracker.collectAndSend { _, _ in + completionCalled = true + } + + tracker.shutdown() + + let expectation = expectation(description: "wait for potential callback") + expectation.isInverted = true + waitForExpectations(timeout: 1) + + XCTAssertFalse(completionCalled) + } + // MARK: - MMTrackingResult func testObjCTrackingResultRedactsDescription() { From e39480ef7add413d1e387b9593cdc685db1a87d5 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 12:16:43 -0400 Subject: [PATCH 27/31] Assert that the stored ID was not persisted --- Tests/MinFraudDeviceTests/DeviceTrackerTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift b/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift index a597132..0731a71 100644 --- a/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift @@ -101,6 +101,7 @@ final class DeviceTrackerTests: XCTestCase { let result = try await tracker.collectAndSend() XCTAssertEqual(result.trackingToken, "abc123:hmac456") + XCTAssertNil(mockStorage.get(forKey: KeychainStorage.storedIDKey)) } func testCollectAndSendPropagatesAPIError() async { From ccb48fe5439752059d5d32a1c29763145a415047 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 12:58:13 -0400 Subject: [PATCH 28/31] Assert IDFV is returned when caching fails --- .../DeviceDataCollectorTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift b/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift index 8047f6e..76ac6e9 100644 --- a/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceDataCollectorTests.swift @@ -60,6 +60,20 @@ final class DeviceDataCollectorTests: XCTestCase { } } + func testCollectReturnsIDFVEvenWhenCachingFails() throws { + let storage = MockKeychainStorage() + storage.shouldFailOnSet = true + let collector = DeviceDataCollector( + storage: storage, + idfvProvider: { "SYSTEM-IDFV-123" } + ) + + let data = try collector.collect() + + XCTAssertEqual(data.idfv, "SYSTEM-IDFV-123") + XCTAssertNil(storage.get(forKey: KeychainStorage.idfvKey)) + } + func testCollectIncludesStoredIDFromKeychain() throws { let storage = MockKeychainStorage() _ = storage.set("existing-stored-id", forKey: KeychainStorage.storedIDKey) From d4c5d9458b203f235be8395f90a7b5116ab8f747 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Thu, 9 Apr 2026 15:49:28 -0400 Subject: [PATCH 29/31] Put an upper bound on collection interval to avoid integer overflow --- CLAUDE.md | 2 +- README.md | 16 ++++++++-------- Sources/MinFraudDevice/Config/SDKConfig.swift | 9 +++++---- Sources/MinFraudDevice/ObjC/MMSDKConfig.swift | 7 ++++--- Tests/MinFraudDeviceTests/ObjCWrapperTests.swift | 8 ++++++++ 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 15e5b0f..0ef0337 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ subclasses and completion-handler APIs. - Immutable configuration with precondition validation - Default servers: `d-ipv6.mmapiws.com` and `d-ipv4.mmapiws.com` - - Collection interval: 0 (disabled) or >= 300 seconds + - Collection interval: 0 (disabled) or 300–86400 seconds 3. **Data Collection Layer** (`Collector/DeviceDataCollector.swift`) diff --git a/README.md b/README.md index f00572e..c3155ea 100644 --- a/README.md +++ b/README.md @@ -57,20 +57,20 @@ let config = SDKConfig( accountID: 123456, // Your MaxMind account ID serverURL: nil, // nil = default dual-stack servers loggingEnabled: false, // enable logging via os.Logger - collectionIntervalSeconds: 0 // 0 = disabled; >= 300 = automatic collection interval in seconds + collectionIntervalSeconds: 0 // 0 = disabled; 300–86400 = automatic collection interval in seconds ) ``` -| Parameter | Type | Default | Description | -| --------------------------- | ---- | ---------- | ---------------------------------------------------------- | -| `accountID` | Int | _required_ | Your MaxMind account ID | -| `serverURL` | URL? | `nil` | Custom server URL (`nil` = default dual-stack servers) | -| `loggingEnabled` | Bool | `false` | Enable logging via `os.Logger` | -| `collectionIntervalSeconds` | Int | `0` | Auto-collection interval in seconds (0 = disabled, >= 300) | +| Parameter | Type | Default | Description | +| --------------------------- | ---- | ---------- | ------------------------------------------------------------- | +| `accountID` | Int | _required_ | Your MaxMind account ID | +| `serverURL` | URL? | `nil` | Custom server URL (`nil` = default dual-stack servers) | +| `loggingEnabled` | Bool | `false` | Enable logging via `os.Logger` | +| `collectionIntervalSeconds` | Int | `0` | Auto-collection interval in seconds (0 = disabled, 300–86400) | ### Automatic Collection -When `collectionIntervalSeconds` is set to a value of 300 or greater, the +When `collectionIntervalSeconds` is set to a value between 300 and 86400, the tracker automatically collects and sends device data at the specified interval: ```swift diff --git a/Sources/MinFraudDevice/Config/SDKConfig.swift b/Sources/MinFraudDevice/Config/SDKConfig.swift index b448f72..f6c2aa1 100644 --- a/Sources/MinFraudDevice/Config/SDKConfig.swift +++ b/Sources/MinFraudDevice/Config/SDKConfig.swift @@ -12,7 +12,7 @@ public struct SDKConfig: Sendable { public let loggingEnabled: Bool /// Automatic collection interval in seconds. Set to `0` to disable, - /// or a value of `300` or greater to enable periodic collection. + /// or a value between `300` and `86400` to enable periodic collection. public let collectionIntervalSeconds: Int /// Creates a new SDK configuration. @@ -22,7 +22,7 @@ public struct SDKConfig: Sendable { /// - serverURL: Custom server URL, or `nil` to use default servers. /// - loggingEnabled: Whether to enable logging. Defaults to `false`. /// - collectionIntervalSeconds: Automatic collection interval in seconds. - /// Must be `0` (disabled) or at least `300`. Defaults to `0`. + /// Must be `0` (disabled) or between `300` and `86400`. Defaults to `0`. public init( accountID: Int, serverURL: URL? = nil, @@ -31,8 +31,9 @@ public struct SDKConfig: Sendable { ) { precondition(accountID > 0, "Account ID must be positive") precondition( - collectionIntervalSeconds == 0 || collectionIntervalSeconds >= 300, - "Collection interval must be 0 (disabled) or at least 300 seconds" + collectionIntervalSeconds == 0 + || (collectionIntervalSeconds >= 300 && collectionIntervalSeconds <= 86_400), + "Collection interval must be 0 (disabled) or between 300 and 86400 seconds" ) self.accountID = accountID self.serverURL = serverURL diff --git a/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift b/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift index e2c0bc0..efff9e5 100644 --- a/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift +++ b/Sources/MinFraudDevice/ObjC/MMSDKConfig.swift @@ -11,14 +11,14 @@ public final class ObjCSDKConfig: NSObject { /// Creates a new SDK configuration with all options. /// /// Returns `nil` if `accountID` is not positive or - /// `collectionIntervalSeconds` is not `0` or at least `300`. + /// `collectionIntervalSeconds` is not `0` or between `300` and `86400`. /// /// - Parameters: /// - accountID: Your MaxMind account ID. Must be positive. /// - serverURL: Custom server URL, or `nil` to use default servers. /// - loggingEnabled: Whether to enable logging. /// - collectionIntervalSeconds: Automatic collection interval in seconds. - /// Must be `0` (disabled) or at least `300`. + /// Must be `0` (disabled) or between `300` and `86400`. @objc public init?( accountID: Int, @@ -27,7 +27,8 @@ public final class ObjCSDKConfig: NSObject { collectionIntervalSeconds: Int ) { guard accountID > 0, - collectionIntervalSeconds == 0 || collectionIntervalSeconds >= 300 + collectionIntervalSeconds == 0 + || (collectionIntervalSeconds >= 300 && collectionIntervalSeconds <= 86_400) else { return nil } diff --git a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift index aa7070d..8ad49e9 100644 --- a/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift +++ b/Tests/MinFraudDeviceTests/ObjCWrapperTests.swift @@ -61,6 +61,14 @@ final class ObjCWrapperTests: XCTestCase { collectionIntervalSeconds: 299 ) ) + XCTAssertNil( + ObjCSDKConfig( + accountID: 12345, + serverURL: nil, + loggingEnabled: false, + collectionIntervalSeconds: 86401 + ) + ) } // MARK: - MMDeviceTracker collectAndSend success From 1214841d68b347c0f741fe46810f349ac5fe9478 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Fri, 10 Apr 2026 08:51:44 -0400 Subject: [PATCH 30/31] Prevent deadlock opportunity If the completion handler called shutdown on the instance, we would have deadlocked. --- Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift index 9947efe..7e0a50e 100644 --- a/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift +++ b/Sources/MinFraudDevice/ObjC/MMDeviceTracker.swift @@ -35,13 +35,15 @@ public final class ObjCDeviceTracker: NSObject { do { let result = try await self.tracker.collectAndSend() self.lock.lock() - defer { self.lock.unlock() } - guard !self.isShutDown else { return } + let shutDown = self.isShutDown + self.lock.unlock() + guard !shutDown else { return } completion(ObjCTrackingResult(result: result), nil) } catch { self.lock.lock() - defer { self.lock.unlock() } - guard !self.isShutDown else { return } + let shutDown = self.isShutDown + self.lock.unlock() + guard !shutDown else { return } completion(nil, error as NSError) } } From 1102803e9e77d1baa9a3b04f539eed5027ccf3b9 Mon Sep 17 00:00:00 2001 From: Patrick Cronin Date: Fri, 10 Apr 2026 09:12:09 -0400 Subject: [PATCH 31/31] Test that collection actually ran or is cancelled --- .../DeviceTrackerTests.swift | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift b/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift index 0731a71..c722b8e 100644 --- a/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift +++ b/Tests/MinFraudDeviceTests/DeviceTrackerTests.swift @@ -159,8 +159,36 @@ final class DeviceTrackerTests: XCTestCase { // MARK: - Shutdown + func testAutomaticCollectionSendsRequest() async throws { + let requestReceived = expectation(description: "Automatic collection sent a request") + + MockURLProtocol.requestHandler = { _ in + requestReceived.fulfill() + let response = HTTPURLResponse( + url: URL(string: "https://test.maxmind.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, Data(self.validResponse.utf8)) + } + + let tracker = makeTracker(collectionIntervalSeconds: 300) + + await fulfillment(of: [requestReceived], timeout: 2) + tracker.shutdown() + } + func testShutdownCancelsAutomaticCollection() async throws { + let secondRequest = expectation(description: "Second automatic collection request") + secondRequest.isInverted = true + + var requestCount = 0 MockURLProtocol.requestHandler = { _ in + requestCount += 1 + if requestCount >= 2 { + secondRequest.fulfill() + } let response = HTTPURLResponse( url: URL(string: "https://test.maxmind.com")!, statusCode: 200, @@ -172,14 +200,13 @@ final class DeviceTrackerTests: XCTestCase { let tracker = makeTracker(collectionIntervalSeconds: 300) - // Give the auto-collection task a moment to start - try await Task.sleep(nanoseconds: 50_000_000) // 50ms + // Wait for the first automatic collection to complete. + try await Task.sleep(nanoseconds: 500_000_000) tracker.shutdown() - // After shutdown, the task should be cancelled - // Give it time to settle and verify no crashes - try await Task.sleep(nanoseconds: 200_000_000) // 200ms + // Verify no further requests are made after shutdown. + await fulfillment(of: [secondRequest], timeout: 1) } // MARK: - Public Init