Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
147288a
Be consistent about logging
PatrickCroninMM Apr 2, 2026
ebe114a
Lock is not required as nothing else could affect the auto task
PatrickCroninMM Apr 2, 2026
7d5b04a
Be explicit about sendability of struct
PatrickCroninMM Apr 2, 2026
d2b349a
Use more complete .whitespacesAndNewlines
PatrickCroninMM Apr 3, 2026
ea49557
Stored ID internal and on the wire, Tracking Token for public interface
PatrickCroninMM Apr 3, 2026
178a538
Add a CI check for breaking public API changes
PatrickCroninMM Apr 3, 2026
688402c
Fixup release instructions w.r.t. location of version constant
PatrickCroninMM Apr 3, 2026
dde6786
Add a thin wrapper layer to support integration with Obj-C apps
PatrickCroninMM Apr 3, 2026
3c6d20a
Public API changes are compared against main only
PatrickCroninMM Apr 7, 2026
b6eb9f6
Support natural Obj-C behavior for failed MMSDKConfig init
PatrickCroninMM Apr 7, 2026
44e07f5
Fix name of returned type
PatrickCroninMM Apr 7, 2026
b4cf313
Prevent cancelled tasks from calling the completion handler in Obj-C …
PatrickCroninMM Apr 7, 2026
5466a6e
Support better error source signals in Obj-C wrapper
PatrickCroninMM Apr 7, 2026
b10547e
Fix-up docs now that DecodingError is dissolved
PatrickCroninMM Apr 7, 2026
b247f95
Fix up dual-IP approach description for Claude
PatrickCroninMM Apr 7, 2026
8efbb3f
Ensure we get the expected stored ID
PatrickCroninMM Apr 7, 2026
72cda92
Complete MinFraudDeviceError' conformance to CustomNSError
PatrickCroninMM Apr 8, 2026
2cbdff6
Facilitate access to detailed error information
PatrickCroninMM Apr 9, 2026
56c4cec
Make sure the completion handler is called exactly once unless specif…
PatrickCroninMM Apr 9, 2026
3982377
Clarify the advantage of a monotonic clock for duration purposes
PatrickCroninMM Apr 9, 2026
4655aad
Document additional response possibility in CLAUDE.md
PatrickCroninMM Apr 9, 2026
c1660c7
Add a missing test case
PatrickCroninMM Apr 9, 2026
67d2ef7
Correct the Xcode version required
PatrickCroninMM Apr 9, 2026
f8be567
Fix awkward sentence
PatrickCroninMM Apr 9, 2026
3f088b5
Fail the test if the received value is not an Int
PatrickCroninMM Apr 9, 2026
39bdad5
Test the promise that calling shutdown will block the completion hand…
PatrickCroninMM Apr 9, 2026
e39480e
Assert that the stored ID was not persisted
PatrickCroninMM Apr 9, 2026
ccb48fe
Assert IDFV is returned when caching fails
PatrickCroninMM Apr 9, 2026
d4c5d94
Put an upper bound on collection interval to avoid integer overflow
PatrickCroninMM Apr 9, 2026
1214841
Prevent deadlock opportunity
PatrickCroninMM Apr 10, 2026
1102803
Test that collection actually ran or is cancelled
PatrickCroninMM Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/api-compat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: API Compatibility

on: [pull_request]

permissions: {}

jobs:
check-api-compat:
name: Check for API Breaking Changes
runs-on: macos-latest
permissions:
contents: read

steps:
- 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
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: latest-stable

- name: Build base branch
run: |
xcodebuild build \
-scheme MinFraudDevice \
-destination 'generic/platform=iOS Simulator' \
-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]}

- name: Check API compatibility
run: |
DIAGS=$(xcrun swift-api-digester -diagnose-sdk \
-module MinFraudDevice \
-baseline-path .build/api-baseline.json \
-sdk "$(xcrun --show-sdk-path --sdk iphonesimulator)" \
-target arm64-apple-ios15.0-simulator \
-I .build/api-compat-pr/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 "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

echo "No API breaking changes detected."
21 changes: 14 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`)
Expand All @@ -94,18 +98,19 @@ instances directly.

- 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`)

- 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`)
- 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)

Expand All @@ -115,13 +120,14 @@ 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 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.

### 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`
Expand All @@ -145,5 +151,6 @@ disabled, `logger` is `nil` and all `logger?.method()` calls are no-ops.

## Error Types

- `MinFraudDeviceError` (public) — `idfvUnavailable`, `missingTrackingToken`
- `APIError` (public) — `serverError(statusCode:message:)`
- `MinFraudDeviceError` (public) — `idfvUnavailable`
- `APIError` (public) — `serverError(statusCode:message:)`,
`responseDecodingFailed(String)`
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ let package = Package(
.testTarget(
name: "MinFraudDeviceTests",
dependencies: ["MinFraudDevice"]
),
.testTarget(
name: "MinFraudDeviceObjCTests",
dependencies: ["MinFraudDevice"]
)
]
)
4 changes: 2 additions & 2 deletions README.dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 36 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -89,6 +89,32 @@ 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];
if (!config) {
// Handle invalid configuration
return;
}
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
Expand Down
8 changes: 5 additions & 3 deletions Sources/MinFraudDevice/Collector/DeviceDataCollector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand All @@ -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")
}

Expand Down
9 changes: 5 additions & 4 deletions Sources/MinFraudDevice/Config/SDKConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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
Expand Down
44 changes: 24 additions & 20 deletions Sources/MinFraudDevice/DeviceTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,28 @@ 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"
}
}
}

extension MinFraudDeviceError: CustomNSError {
public static var errorDomain: String { SDKConfig.identifier }

public var errorCode: Int {
switch self {
case .idfvUnavailable: return 1
}
}

public var errorUserInfo: [String: Any] {
[NSLocalizedDescriptionKey: errorDescription ?? "Unknown error"]
}
}

/// Main entry point for the MinFraud Device SDK.
///
/// Create an instance with an ``SDKConfig``, then call ``collectAndSend()``
Expand Down Expand Up @@ -81,28 +90,25 @@ 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``
/// if the network request fails.
/// identifier cannot be obtained, or an ``APIError``
/// 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)

guard let token = response.trackingToken,
!token.trimmingCharacters(in: .whitespaces).isEmpty else {
throw MinFraudDeviceError.missingTrackingToken
}

if storage.set(token, forKey: KeychainStorage.trackingTokenKey) {
logger?.debug("Tracking token saved from server response")
if storage.set(response.storedID, forKey: KeychainStorage.storedIDKey) {
logger?.debug("Cached stored ID from server response in keychain")
} else {
logger?.warning("Failed to cache stored ID in keychain")
}

return TrackingResult(trackingToken: token)
return TrackingResult(trackingToken: response.storedID)
}

/// Cancels automatic collection and releases resources.
Expand Down Expand Up @@ -140,8 +146,6 @@ public final class DeviceTracker: @unchecked Sendable {
}

deinit {
lock.lock()
defer { lock.unlock() }
automaticCollectionTask?.cancel()
}
}
Loading
Loading