Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.2.0 (unreleased)

- **Breaking:** `collectAndSend()` now returns `Result<TrackingResult>` instead
of `Result<Unit>`. The `TrackingResult` contains a `trackingToken` property
for use with the minFraud API's `/device/tracking_token` field.
- **Breaking:** `collectDeviceData()` and `sendDeviceData()` are no longer part
of the public API. Use `collectAndSend()` instead.

## 0.1.0 (2026-01-09)

- Initial release
36 changes: 25 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ The SDK uses a **singleton pattern with initialization guard**:
1. **Public API Layer** (`DeviceTracker.kt`)
- Singleton facade pattern
- Both suspend functions and callback-based methods
- Returns `Result<TrackingResult>` containing tracking token
- Example: `collectAndSend()` (suspend) and `collectAndSend(callback)`
(callbacks)

Expand All @@ -137,27 +138,41 @@ The SDK uses a **singleton pattern with initialization guard**:
- Ktor HTTP client with Android engine
- kotlinx.serialization for JSON
- Optional logging based on `enableLogging` config
- Returns `Result<ServerResponse>` for error handling
- Returns `Result<ServerResponse>` internally for error handling

**Dual-Request Flow (IPv6/IPv4):** To capture both IP addresses for a device,
the SDK uses a dual-request flow:
1. First request sent to `d-ipv6.mmapiws.com/device/android`
2. If response contains `ip_version: 6`, a second request is sent to
`d-ipv4.mmapiws.com/device/android`
3. The IPv4 request is fire-and-forget (failures don't affect the result)
4. The `stored_id` from the IPv6 response is returned and persisted
4. The stored ID from the IPv6 response is returned and persisted

If a custom server URL is configured via `SdkConfig.Builder.serverUrl()`, the
dual-request flow is disabled and only a single request is sent.

### Terminology: Stored ID vs Tracking Token

These are two names for related but distinct concepts:

- **Stored ID** (`stored_id`): The server-generated identifier returned in the
API response and persisted locally via `StoredIDStorage`. Used internally
throughout the SDK (model classes, storage, network layer, comments, logs).
- **Tracking token** (`trackingToken`): The value exposed to SDK consumers via
`TrackingResult.trackingToken`, intended for passing to the minFraud API's
`/device/tracking_token` field.

Today they happen to be the same value, but the abstraction exists so the
public-facing token format can change independently of the internal stored ID.
Use "stored ID" in internal code and "tracking token" only in public API
surfaces.

### Data Model

**DeviceData** (`model/DeviceData.kt`):

- Marked with `@Serializable` for kotlinx.serialization
- All fields are public for Java compatibility
- Immutable data class
- Optional `deviceId` field (can be null)
- Internal data class marked with `@Serializable` for kotlinx.serialization
- Immutable with default values for optional fields

## Java Compatibility Strategy

Expand All @@ -176,17 +191,17 @@ When adding new public APIs:

```kotlin
@JvmOverloads
public fun collectAndSend(callback: ((Result<Unit>) -> Unit)? = null)
public fun collectAndSend(callback: ((Result<TrackingResult>) -> Unit)? = null)
```

3. **Provide callback-based alternatives to suspend functions**

```kotlin
// Suspend function for Kotlin
suspend fun collectAndSend(): Result<Unit>
suspend fun collectAndSend(): Result<TrackingResult>

// Callback version for Java
fun collectAndSend(callback: (Result<Unit>) -> Unit)
fun collectAndSend(callback: (Result<TrackingResult>) -> Unit)
```

4. **Use explicit visibility modifiers**
Expand Down Expand Up @@ -216,9 +231,8 @@ All dependencies are centralized in `gradle/libs.versions.toml`:

The SDK includes consumer ProGuard rules in `consumer-rules.pro`:

- Keeps public SDK API
- Keeps public SDK API classes (`DeviceTracker`, `SdkConfig`, `TrackingResult`)
- Keeps kotlinx.serialization classes
- Keeps Ktor classes
- Apps using this SDK automatically inherit these rules

## Environment Setup
Expand Down
56 changes: 40 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ class MyApplication : Application() {
```kotlin
lifecycleScope.launch {
DeviceTracker.getInstance().collectAndSend()
.onSuccess {
Log.d("SDK", "Data sent successfully")
.onSuccess { trackingResult ->
// Pass the token to your backend for minFraud integration
sendToBackend(trackingResult.trackingToken)
}
.onFailure { error ->
Log.e("SDK", "Failed to send data", error)
Expand All @@ -71,8 +72,8 @@ lifecycleScope.launch {

```kotlin
DeviceTracker.getInstance().collectAndSend { result ->
result.onSuccess {
Log.d("SDK", "Data sent successfully")
result.onSuccess { trackingResult ->
sendToBackend(trackingResult.trackingToken)
}.onFailure { error ->
Log.e("SDK", "Failed to send data", error)
}
Expand All @@ -81,24 +82,47 @@ DeviceTracker.getInstance().collectAndSend { result ->

#### Java Example

```java
DeviceTracker.getInstance().collectAndSend(result -> {
if (result.isSuccess()) {
Log.d("SDK", "Data sent successfully");
} else {
Throwable error = result.exceptionOrNull();
Log.e("SDK", "Failed to send data", error);
Kotlin's `Result<T>` is a value class with limited Java interop. If you need the
tracking token in Java, add a thin Kotlin bridge to your project:

```kotlin
// In your project (e.g., DeviceTrackerBridge.kt)
fun collectAndSend(
tracker: DeviceTracker,
onSuccess: java.util.function.Consumer<String>,
onFailure: java.util.function.Consumer<Throwable>,
) {
tracker.collectAndSend { result ->
result.onSuccess { onSuccess.accept(it.trackingToken) }
.onFailure { onFailure.accept(it) }
}
});
}
```

### 3. Manual Data Collection
```java
// Java caller
collectAndSend(
DeviceTracker.getInstance(),
token -> sendToBackend(token),
error -> Log.e("SDK", "Failed to send data", error)
);
```

Collect device data without sending:
### 3. Linking Device Data to minFraud Transactions

After collecting and sending device data, pass the tracking token to the
minFraud API to link device data with transactions:

```kotlin
val deviceData = DeviceTracker.getInstance().collectDeviceData()
println("Device: ${deviceData.build.manufacturer} ${deviceData.build.model}")
lifecycleScope.launch {
DeviceTracker.getInstance().collectAndSend()
.onSuccess { trackingResult ->
// Pass trackingResult.trackingToken to your backend,
// then include it in the minFraud request's
// /device/tracking_token field
sendToBackend(trackingResult.trackingToken)
}
}
```

## Configuration Options
Expand Down
10 changes: 3 additions & 7 deletions device-sdk/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
# Consumer ProGuard rules for MaxMind Device SDK
# These rules will be automatically applied to apps that use this library

# Keep public SDK API
-keep public class com.maxmind.device.** {
public protected *;
}

# Keep SDK entry points
-keep class com.maxmind.device.DeviceTracker { *; }

Expand All @@ -16,9 +11,10 @@
# Kotlin serialization rules for SDK data classes
-keepattributes InnerClasses

# Keep all model classes (data classes with @Serializable)
-keep class com.maxmind.device.model.** { *; }
# Keep public model classes
-keep class com.maxmind.device.model.TrackingResult { *; }

# Keep serialization support for all model classes (needed at runtime)
-keepclassmembers class com.maxmind.device.model.** {
*** Companion;
kotlinx.serialization.KSerializer serializer(...);
Expand Down
93 changes: 61 additions & 32 deletions device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.util.Log
import com.maxmind.device.collector.DeviceDataCollector
import com.maxmind.device.config.SdkConfig
import com.maxmind.device.model.DeviceData
import com.maxmind.device.model.TrackingResult
import com.maxmind.device.network.DeviceApiClient
import com.maxmind.device.storage.StoredIDStorage
import kotlinx.coroutines.CoroutineScope
Expand All @@ -14,6 +15,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException

/**
* Main entry point for the MaxMind Device Tracking SDK.
Expand All @@ -32,8 +34,8 @@ import kotlinx.coroutines.launch
*
* // Collect and send device data
* DeviceTracker.getInstance().collectAndSend { result ->
* result.onSuccess {
* Log.d("SDK", "Data sent successfully")
* result.onSuccess { trackingResult ->
* sendToBackend(trackingResult.trackingToken)
* }.onFailure { error ->
* Log.e("SDK", "Failed to send data", error)
* }
Expand Down Expand Up @@ -67,49 +69,76 @@ public class DeviceTracker private constructor(
*
* @return [DeviceData] containing collected device information
*/
public fun collectDeviceData(): DeviceData = deviceDataCollector.collect()
internal fun collectDeviceData(): DeviceData = deviceDataCollector.collect()

/**
* Sends device data to MaxMind servers.
*
* This is a suspending function that should be called from a coroutine.
* On success, saves the server-generated stored ID for future requests.
* On success, attempts to persist the tracking token locally for future requests.
* Storage failures do not cause the operation to fail.
*
* @param deviceData The device data to send
* @return [Result] indicating success or failure
* @return [Result] containing the [TrackingResult] with tracking token, or failure
*/
public suspend fun sendDeviceData(deviceData: DeviceData): Result<Unit> =
apiClient.sendDeviceData(deviceData).map { response ->
// Save the stored ID from the server response
response.storedID?.let { id ->
storedIDStorage.save(id)
if (config.enableLogging) {
Log.d(TAG, "Stored ID saved from server response")
}
@Suppress("ReturnCount")
internal suspend fun sendDeviceData(deviceData: DeviceData): Result<TrackingResult> {
val response =
apiClient.sendDeviceData(deviceData).getOrElse { return Result.failure(it) }
val token =
response.storedID
?: return Result.failure(IllegalStateException("Server response missing tracking token"))
val result =
try {
TrackingResult(trackingToken = token)
} catch (e: IllegalArgumentException) {
return Result.failure(e)
}
try {
storedIDStorage.save(token)
if (config.enableLogging) {
Log.d(TAG, "Stored ID saved from server response")
}
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Exception,
) {
if (config.enableLogging) {
Log.e(TAG, "Failed to save stored ID to local storage", e)
}
}
return Result.success(result)
}

/**
* Collects device data and sends it to MaxMind servers in one operation.
*
* This is a suspending function that should be called from a coroutine.
*
* @return [Result] indicating success or failure
* @return [Result] containing the [TrackingResult] with tracking token, or failure
*/
public suspend fun collectAndSend(): Result<Unit> {
val deviceData = collectDeviceData()
return sendDeviceData(deviceData)
}
public suspend fun collectAndSend(): Result<TrackingResult> =
try {
val deviceData = collectDeviceData()
sendDeviceData(deviceData)
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Exception,
) {
Result.failure(e)
}

/**
* Collects device data and sends it asynchronously with a callback.
*
* This is a convenience method for Java compatibility and simpler usage.
*
* @param callback Callback invoked when the operation completes
* @param callback Callback invoked with a [Result] containing [TrackingResult] on success
*/
@JvmOverloads
public fun collectAndSend(callback: ((Result<Unit>) -> Unit)? = null) {
public fun collectAndSend(callback: ((Result<TrackingResult>) -> Unit)? = null) {
coroutineScope.launch {
val result = collectAndSend()
callback?.invoke(result)
Expand All @@ -119,18 +148,18 @@ public class DeviceTracker private constructor(
private fun startAutomaticCollection() {
coroutineScope.launch {
while (isActive) {
try {
collectAndSend()
if (config.enableLogging) {
Log.d(TAG, "Automatic device data collection completed")
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Exception,
) {
if (config.enableLogging) {
Log.e(TAG, "Automatic collection failed", e)
}
}
collectAndSend().fold(
onSuccess = {
if (config.enableLogging) {
Log.d(TAG, "Automatic device data collection completed")
}
},
onFailure = { e ->
if (config.enableLogging) {
Log.e(TAG, "Automatic collection failed", e)
}
},
)
delay(config.collectionIntervalMs)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,14 @@ public data class SdkConfig internal constructor(
}
}

public companion object {
internal companion object {
/** Default IPv6 server host */
public const val DEFAULT_IPV6_HOST: String = "d-ipv6.mmapiws.com"
internal const val DEFAULT_IPV6_HOST: String = "d-ipv6.mmapiws.com"

/** Default IPv4 server host */
public const val DEFAULT_IPV4_HOST: String = "d-ipv4.mmapiws.com"
internal const val DEFAULT_IPV4_HOST: String = "d-ipv4.mmapiws.com"

/** API endpoint path */
public const val ENDPOINT_PATH: String = "/device/android"
internal const val ENDPOINT_PATH: String = "/device/android"
Comment on lines +98 to +106

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Marking these constants as internal is a good step in reducing the public API surface. No comment, just acknowledging the change.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
* Audio hardware profile from AudioManager.
*/
@Serializable
public data class AudioInfo(
internal data class AudioInfo(
@SerialName("output_sample_rate")
val outputSampleRate: String? = null,
@SerialName("output_frames_per_buffer")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
* Behavioral signals from user configuration.
*/
@Serializable
public data class BehaviorInfo(
internal data class BehaviorInfo(
@SerialName("enabled_keyboards")
val enabledKeyboards: List<String> = emptyList(),
@SerialName("enabled_accessibility_services")
Expand Down
Loading
Loading