From 8ff3ad9cfd4c542cd49465aa721573b8e3da7c32 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 26 Feb 2026 11:46:04 -0800 Subject: [PATCH 01/15] Add precious to mise.toml Co-Authored-By: Claude Opus 4.6 --- mise.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/mise.toml b/mise.toml index 641898a..4214342 100644 --- a/mise.toml +++ b/mise.toml @@ -3,3 +3,4 @@ java = "temurin-21" # yq is used by release.sh to parse ~/.m2/settings.xml for Maven Central credentials yq = "latest" +"github:houseabsolute/precious" = "latest" From 052b6e1777afd4e19091d83fdd79c5995313cc1b Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 26 Feb 2026 11:46:11 -0800 Subject: [PATCH 02/15] Return TrackingResult from public API instead of Result collectAndSend() and sendDeviceData() now return Result with an opaque trackingToken for use with the minFraud API. This avoids exposing ServerResponse internals and treats the token as opaque, since its format may change. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 7 + CLAUDE.md | 13 +- README.md | 28 +++- .../java/com/maxmind/device/DeviceTracker.kt | 64 +++++---- .../maxmind/device/model/TrackingResult.kt | 16 +++ .../maxmind/device/network/DeviceApiClient.kt | 2 +- .../com/maxmind/device/DeviceTrackerTest.kt | 130 ++++++++++++++++++ .../com/maxmind/device/sample/MainActivity.kt | 4 +- 8 files changed, 225 insertions(+), 39 deletions(-) create mode 100644 device-sdk/src/main/java/com/maxmind/device/model/TrackingResult.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 338386f..897b90a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.2.0 (unreleased) + +- **Breaking:** `collectAndSend()` and `sendDeviceData()` now return + `Result` instead of `Result`. The `TrackingResult` + contains a `trackingToken` property for use with the minFraud API's + `/device/tracking_token` field. + ## 0.1.0 (2026-01-09) - Initial release diff --git a/CLAUDE.md b/CLAUDE.md index e17d0bd..6ac1732 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` containing tracking token - Example: `collectAndSend()` (suspend) and `collectAndSend(callback)` (callbacks) @@ -137,7 +138,7 @@ 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` for error handling + - Returns `Result` internally for error handling **Dual-Request Flow (IPv6/IPv4):** To capture both IP addresses for a device, the SDK uses a dual-request flow: @@ -145,7 +146,7 @@ The SDK uses a **singleton pattern with initialization guard**: 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 tracking token 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. @@ -157,7 +158,7 @@ The SDK uses a **singleton pattern with initialization guard**: - Marked with `@Serializable` for kotlinx.serialization - All fields are public for Java compatibility - Immutable data class -- Optional `deviceId` field (can be null) +- Optional `storedID` field (defaults to empty `StoredID`) ## Java Compatibility Strategy @@ -176,17 +177,17 @@ When adding new public APIs: ```kotlin @JvmOverloads - public fun collectAndSend(callback: ((Result) -> Unit)? = null) + public fun collectAndSend(callback: ((Result) -> Unit)? = null) ``` 3. **Provide callback-based alternatives to suspend functions** ```kotlin // Suspend function for Kotlin - suspend fun collectAndSend(): Result + suspend fun collectAndSend(): Result // Callback version for Java - fun collectAndSend(callback: (Result) -> Unit) + fun collectAndSend(callback: (Result) -> Unit) ``` 4. **Use explicit visibility modifiers** diff --git a/README.md b/README.md index 75f5f0d..7daf8f8 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ class MyApplication : Application() { ```kotlin lifecycleScope.launch { DeviceTracker.getInstance().collectAndSend() - .onSuccess { - Log.d("SDK", "Data sent successfully") + .onSuccess { trackingResult -> + Log.d("SDK", "Tracking token: ${trackingResult.trackingToken}") } .onFailure { error -> Log.e("SDK", "Failed to send data", error) @@ -71,8 +71,8 @@ lifecycleScope.launch { ```kotlin DeviceTracker.getInstance().collectAndSend { result -> - result.onSuccess { - Log.d("SDK", "Data sent successfully") + result.onSuccess { trackingResult -> + Log.d("SDK", "Tracking token: ${trackingResult.trackingToken}") }.onFailure { error -> Log.e("SDK", "Failed to send data", error) } @@ -84,7 +84,8 @@ DeviceTracker.getInstance().collectAndSend { result -> ```java DeviceTracker.getInstance().collectAndSend(result -> { if (result.isSuccess()) { - Log.d("SDK", "Data sent successfully"); + TrackingResult trackingResult = result.getOrThrow(); + Log.d("SDK", "Tracking token: " + trackingResult.getTrackingToken()); } else { Throwable error = result.exceptionOrNull(); Log.e("SDK", "Failed to send data", error); @@ -101,6 +102,23 @@ val deviceData = DeviceTracker.getInstance().collectDeviceData() println("Device: ${deviceData.build.manufacturer} ${deviceData.build.model}") ``` +### 4. 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 +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 ### SdkConfig.Builder diff --git a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt index 33391b4..1877484 100644 --- a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt +++ b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt @@ -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 @@ -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. @@ -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 -> + * Log.d("SDK", "Tracking token: ${trackingResult.trackingToken}") * }.onFailure { error -> * Log.e("SDK", "Failed to send data", error) * } @@ -73,20 +75,32 @@ public class DeviceTracker private constructor( * 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 = - apiClient.sendDeviceData(deviceData).map { response -> - // Save the stored ID from the server response - response.storedID?.let { id -> - storedIDStorage.save(id) + public suspend fun sendDeviceData(deviceData: DeviceData): Result = + apiClient.sendDeviceData(deviceData).mapCatching { response -> + val token = + response.storedID + ?: throw IllegalStateException("Server response missing tracking token") + 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) + } } + TrackingResult(trackingToken = token) } /** @@ -94,9 +108,9 @@ public class DeviceTracker private constructor( * * 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 { + public suspend fun collectAndSend(): Result { val deviceData = collectDeviceData() return sendDeviceData(deviceData) } @@ -106,10 +120,10 @@ public class DeviceTracker private constructor( * * 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)? = null) { + public fun collectAndSend(callback: ((Result) -> Unit)? = null) { coroutineScope.launch { val result = collectAndSend() callback?.invoke(result) @@ -119,18 +133,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) } } diff --git a/device-sdk/src/main/java/com/maxmind/device/model/TrackingResult.kt b/device-sdk/src/main/java/com/maxmind/device/model/TrackingResult.kt new file mode 100644 index 0000000..f80c49a --- /dev/null +++ b/device-sdk/src/main/java/com/maxmind/device/model/TrackingResult.kt @@ -0,0 +1,16 @@ +package com.maxmind.device.model + +/** + * Result of a device tracking operation. + * + * @property trackingToken Opaque token to pass to the minFraud API's + * `/device/tracking_token` field. Do not parse this value or rely on + * its format, which may change without notice. + */ +public data class TrackingResult( + val trackingToken: String, +) { + init { + require(trackingToken.isNotBlank()) { "Tracking token must not be blank" } + } +} diff --git a/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt b/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt index a0d75c0..17e27f4 100644 --- a/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt +++ b/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt @@ -75,7 +75,7 @@ internal class DeviceApiClient( * If a custom server URL is set, sends only to that URL. * * @param deviceData The device data to send - * @return [Result] containing the server response with stored ID, or an error + * @return [Result] containing the server response with tracking token, or an error */ suspend fun sendDeviceData(deviceData: DeviceData): Result = if (config.useDefaultServers) { diff --git a/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt b/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt index 29491db..ab69580 100644 --- a/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt +++ b/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt @@ -2,8 +2,15 @@ package com.maxmind.device import android.content.Context import com.maxmind.device.config.SdkConfig +import com.maxmind.device.model.ServerResponse +import com.maxmind.device.model.TrackingResult +import com.maxmind.device.network.DeviceApiClient +import com.maxmind.device.storage.StoredIDStorage +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull @@ -160,6 +167,129 @@ internal class DeviceTrackerTest { assertEquals("Account ID must be positive", exception.message) } + // ========== sendDeviceData Tests ========== + + @Test + @Order(10) + internal fun `10 sendDeviceData saves tracking token on success`() = + runTest { + val tracker = createTrackerWithMocks() + val mockApiClient = getField(tracker, "apiClient") + val mockStorage = getField(tracker, "storedIDStorage") + + val serverResponse = ServerResponse(storedID = "abc:hmac", ipVersion = 6) + coEvery { mockApiClient.sendDeviceData(any()) } returns Result.success(serverResponse) + + val result = tracker.sendDeviceData(mockk(relaxed = true)) + + assertTrue(result.isSuccess) + assertEquals(TrackingResult(trackingToken = "abc:hmac"), result.getOrNull()) + verify { mockStorage.save("abc:hmac") } + } + + @Test + @Order(11) + internal fun `11 sendDeviceData fails when tracking token is null`() = + runTest { + val tracker = createTrackerWithMocks() + val mockApiClient = getField(tracker, "apiClient") + + val response = ServerResponse(storedID = null) + coEvery { mockApiClient.sendDeviceData(any()) } returns Result.success(response) + + val result = tracker.sendDeviceData(mockk(relaxed = true)) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalStateException) + assertEquals("Server response missing tracking token", result.exceptionOrNull()?.message) + } + + @Test + @Order(12) + internal fun `12 sendDeviceData propagates API client failure`() = + runTest { + val tracker = createTrackerWithMocks() + val mockApiClient = getField(tracker, "apiClient") + + val error = DeviceApiClient.ApiException("Server returned 500: Internal Server Error") + coEvery { mockApiClient.sendDeviceData(any()) } returns Result.failure(error) + + val result = tracker.sendDeviceData(mockk(relaxed = true)) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DeviceApiClient.ApiException) + } + + @Test + @Order(13) + internal fun `13 sendDeviceData succeeds even when storage save fails`() = + runTest { + val tracker = createTrackerWithMocks() + val mockApiClient = getField(tracker, "apiClient") + val mockStorage = getField(tracker, "storedIDStorage") + + val serverResponse = ServerResponse(storedID = "abc:hmac", ipVersion = 6) + coEvery { mockApiClient.sendDeviceData(any()) } returns Result.success(serverResponse) + every { mockStorage.save(any()) } throws RuntimeException("disk full") + + val result = tracker.sendDeviceData(mockk(relaxed = true)) + + assertTrue(result.isSuccess) + assertEquals(TrackingResult(trackingToken = "abc:hmac"), result.getOrNull()) + } + + @Test + @Order(14) + internal fun `14 sendDeviceData fails when tracking token is blank`() = + runTest { + val tracker = createTrackerWithMocks() + val mockApiClient = getField(tracker, "apiClient") + + val response = ServerResponse(storedID = "", ipVersion = 6) + coEvery { mockApiClient.sendDeviceData(any()) } returns Result.success(response) + + val result = tracker.sendDeviceData(mockk(relaxed = true)) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + /** + * Creates a DeviceTracker with mock apiClient and storedIDStorage injected via reflection. + */ + private fun createTrackerWithMocks(): DeviceTracker { + resetSingleton() + val tracker = DeviceTracker.initialize(mockContext, config) + + val mockApiClient = mockk(relaxed = true) + val mockStorage = mockk(relaxed = true) + + setField(tracker, "apiClient", mockApiClient) + setField(tracker, "storedIDStorage", mockStorage) + + return tracker + } + + @Suppress("UNCHECKED_CAST") + private fun getField( + obj: Any, + fieldName: String, + ): T { + val field = obj::class.java.getDeclaredField(fieldName) + field.isAccessible = true + return field.get(obj) as T + } + + private fun setField( + obj: Any, + fieldName: String, + value: Any, + ) { + val field = obj::class.java.getDeclaredField(fieldName) + field.isAccessible = true + field.set(obj, value) + } + /** * Resets the singleton instance using reflection. * diff --git a/sample/src/main/java/com/maxmind/device/sample/MainActivity.kt b/sample/src/main/java/com/maxmind/device/sample/MainActivity.kt index 27722ab..e94c0c5 100644 --- a/sample/src/main/java/com/maxmind/device/sample/MainActivity.kt +++ b/sample/src/main/java/com/maxmind/device/sample/MainActivity.kt @@ -209,8 +209,8 @@ class MainActivity : AppCompatActivity() { lifecycleScope.launch { sdk.collectAndSend().fold( - onSuccess = { - appendLog("✓ Data sent successfully!") + onSuccess = { response -> + appendLog("✓ Data sent! Tracking token: ${response.trackingToken}") showMessage("Data sent successfully") }, onFailure = { error -> From 2a77223a8de692d85d66ece6a48f20f7aad522de Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 26 Feb 2026 13:42:30 -0800 Subject: [PATCH 03/15] Minimize public API surface to only consumer-facing types Make internal: 18 model classes, ServerResponse, SdkConfig host constants, collectDeviceData(), and sendDeviceData(). Only TrackingResult, DeviceTracker, and SdkConfig+Builder remain public. Simplify sample app to use only collectAndSend(), removing DeviceData inspection, reflection, and serialization dependencies. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 + .../java/com/maxmind/device/DeviceTracker.kt | 6 +- .../com/maxmind/device/config/SdkConfig.kt | 8 +- .../com/maxmind/device/model/AudioInfo.kt | 2 +- .../com/maxmind/device/model/BehaviorInfo.kt | 2 +- .../com/maxmind/device/model/BuildInfo.kt | 2 +- .../com/maxmind/device/model/CameraInfo.kt | 2 +- .../com/maxmind/device/model/CodecInfo.kt | 4 +- .../com/maxmind/device/model/DeviceData.kt | 2 +- .../com/maxmind/device/model/DeviceIDs.kt | 2 +- .../com/maxmind/device/model/DisplayInfo.kt | 2 +- .../java/com/maxmind/device/model/FontInfo.kt | 2 +- .../java/com/maxmind/device/model/GpuInfo.kt | 2 +- .../com/maxmind/device/model/HardwareInfo.kt | 2 +- .../maxmind/device/model/InstallationInfo.kt | 2 +- .../com/maxmind/device/model/LocaleInfo.kt | 2 +- .../com/maxmind/device/model/NetworkInfo.kt | 2 +- .../com/maxmind/device/model/SensorInfo.kt | 2 +- .../maxmind/device/model/ServerResponse.kt | 2 +- .../java/com/maxmind/device/model/StoredID.kt | 2 +- .../maxmind/device/model/SystemSettings.kt | 2 +- .../com/maxmind/device/model/TelephonyInfo.kt | 2 +- sample/build.gradle.kts | 3 - .../com/maxmind/device/sample/MainActivity.kt | 119 +----------------- sample/src/main/res/layout/activity_main.xml | 10 -- sample/src/main/res/values/strings.xml | 9 -- 26 files changed, 30 insertions(+), 167 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 897b90a..0d1c7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ `Result` instead of `Result`. 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) diff --git a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt index 1877484..ac06769 100644 --- a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt +++ b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt @@ -69,7 +69,7 @@ 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. @@ -81,11 +81,11 @@ public class DeviceTracker private constructor( * @param deviceData The device data to send * @return [Result] containing the [TrackingResult] with tracking token, or failure */ - public suspend fun sendDeviceData(deviceData: DeviceData): Result = + internal suspend fun sendDeviceData(deviceData: DeviceData): Result = apiClient.sendDeviceData(deviceData).mapCatching { response -> val token = response.storedID - ?: throw IllegalStateException("Server response missing tracking token") + ?: error("Server response missing tracking token") try { storedIDStorage.save(token) if (config.enableLogging) { diff --git a/device-sdk/src/main/java/com/maxmind/device/config/SdkConfig.kt b/device-sdk/src/main/java/com/maxmind/device/config/SdkConfig.kt index a8d1fad..d458b0e 100644 --- a/device-sdk/src/main/java/com/maxmind/device/config/SdkConfig.kt +++ b/device-sdk/src/main/java/com/maxmind/device/config/SdkConfig.kt @@ -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" } } diff --git a/device-sdk/src/main/java/com/maxmind/device/model/AudioInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/AudioInfo.kt index 5f3a609..9a35d82 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/AudioInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/AudioInfo.kt @@ -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") diff --git a/device-sdk/src/main/java/com/maxmind/device/model/BehaviorInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/BehaviorInfo.kt index 44968a5..a2dfcc6 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/BehaviorInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/BehaviorInfo.kt @@ -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 = emptyList(), @SerialName("enabled_accessibility_services") diff --git a/device-sdk/src/main/java/com/maxmind/device/model/BuildInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/BuildInfo.kt index cadb5e9..4e40ecd 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/BuildInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/BuildInfo.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * Device build information from android.os.Build. */ @Serializable -public data class BuildInfo( +internal data class BuildInfo( val fingerprint: String, val manufacturer: String, val model: String, diff --git a/device-sdk/src/main/java/com/maxmind/device/model/CameraInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/CameraInfo.kt index b699a7c..ba3f4ed 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/CameraInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/CameraInfo.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * Camera hardware capabilities from CameraManager. */ @Serializable -public data class CameraInfo( +internal data class CameraInfo( @SerialName("camera_id") val cameraID: String, val facing: Int, diff --git a/device-sdk/src/main/java/com/maxmind/device/model/CodecInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/CodecInfo.kt index 7f8bc07..8b75217 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/CodecInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/CodecInfo.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * Media codec support information. */ @Serializable -public data class CodecInfo( +internal data class CodecInfo( val audio: List = emptyList(), val video: List = emptyList(), ) @@ -16,7 +16,7 @@ public data class CodecInfo( * Details about a specific codec. */ @Serializable -public data class CodecDetail( +internal data class CodecDetail( val name: String, @SerialName("supported_types") val supportedTypes: List = emptyList(), diff --git a/device-sdk/src/main/java/com/maxmind/device/model/DeviceData.kt b/device-sdk/src/main/java/com/maxmind/device/model/DeviceData.kt index 4ec255f..1adb433 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/DeviceData.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/DeviceData.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable * and sent to MaxMind servers for device fingerprinting and fraud detection. */ @Serializable -public data class DeviceData( +internal data class DeviceData( // Server-generated stored ID (like browser cookies) @SerialName("stored_id") val storedID: StoredID = StoredID(), diff --git a/device-sdk/src/main/java/com/maxmind/device/model/DeviceIDs.kt b/device-sdk/src/main/java/com/maxmind/device/model/DeviceIDs.kt index 76132e8..3a92c8e 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/DeviceIDs.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/DeviceIDs.kt @@ -13,7 +13,7 @@ import kotlinx.serialization.Serializable * @property androidID App-scoped ID from Settings.Secure, persists across reinstalls */ @Serializable -public data class DeviceIDs( +internal data class DeviceIDs( @SerialName("media_drm_id") val mediaDrmID: String? = null, @SerialName("android_id") diff --git a/device-sdk/src/main/java/com/maxmind/device/model/DisplayInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/DisplayInfo.kt index 6af06d9..7bdd737 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/DisplayInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/DisplayInfo.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * Display characteristics from DisplayMetrics. */ @Serializable -public data class DisplayInfo( +internal data class DisplayInfo( @SerialName("width_pixels") val widthPixels: Int, @SerialName("height_pixels") diff --git a/device-sdk/src/main/java/com/maxmind/device/model/FontInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/FontInfo.kt index 8c65d94..48fccef 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/FontInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/FontInfo.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * Font profile information based on available system fonts. */ @Serializable -public data class FontInfo( +internal data class FontInfo( @SerialName("available_fonts") val availableFonts: List = emptyList(), ) diff --git a/device-sdk/src/main/java/com/maxmind/device/model/GpuInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/GpuInfo.kt index f93347b..86d1071 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/GpuInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/GpuInfo.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable * GPU information from OpenGL ES. */ @Serializable -public data class GpuInfo( +internal data class GpuInfo( val renderer: String? = null, val vendor: String? = null, val version: String? = null, diff --git a/device-sdk/src/main/java/com/maxmind/device/model/HardwareInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/HardwareInfo.kt index 0070d0e..8b43c3d 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/HardwareInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/HardwareInfo.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * Hardware resource information. */ @Serializable -public data class HardwareInfo( +internal data class HardwareInfo( @SerialName("cpu_cores") val cpuCores: Int, @SerialName("total_memory_bytes") diff --git a/device-sdk/src/main/java/com/maxmind/device/model/InstallationInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/InstallationInfo.kt index 98637f4..50e8e07 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/InstallationInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/InstallationInfo.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * App installation metadata. */ @Serializable -public data class InstallationInfo( +internal data class InstallationInfo( @SerialName("first_install_time") val firstInstallTime: Long, @SerialName("last_update_time") diff --git a/device-sdk/src/main/java/com/maxmind/device/model/LocaleInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/LocaleInfo.kt index 9fc18c0..185272c 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/LocaleInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/LocaleInfo.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable * Locale and regional information. */ @Serializable -public data class LocaleInfo( +internal data class LocaleInfo( val language: String, val country: String, val timezone: String, diff --git a/device-sdk/src/main/java/com/maxmind/device/model/NetworkInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/NetworkInfo.kt index 0f418d3..290f72e 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/NetworkInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/NetworkInfo.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * Network context information. */ @Serializable -public data class NetworkInfo( +internal data class NetworkInfo( @SerialName("connection_type") val connectionType: String? = null, @SerialName("is_metered") diff --git a/device-sdk/src/main/java/com/maxmind/device/model/SensorInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/SensorInfo.kt index a497c75..52a0b3f 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/SensorInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/SensorInfo.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * Information about a device sensor. */ @Serializable -public data class SensorInfo( +internal data class SensorInfo( val name: String, val vendor: String, val type: Int, diff --git a/device-sdk/src/main/java/com/maxmind/device/model/ServerResponse.kt b/device-sdk/src/main/java/com/maxmind/device/model/ServerResponse.kt index b0efd24..08631e5 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/ServerResponse.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/ServerResponse.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable * @property ipVersion The IP version used for the request (4 or 6) */ @Serializable -public data class ServerResponse( +internal data class ServerResponse( @SerialName("stored_id") val storedID: String? = null, @SerialName("ip_version") diff --git a/device-sdk/src/main/java/com/maxmind/device/model/StoredID.kt b/device-sdk/src/main/java/com/maxmind/device/model/StoredID.kt index 3bf3837..127ced8 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/StoredID.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/StoredID.kt @@ -14,6 +14,6 @@ import kotlinx.serialization.Serializable * @property id The stored ID string in format "{uuid}:{hmac}", or null if not yet received from server */ @Serializable -public data class StoredID( +internal data class StoredID( val id: String? = null, ) diff --git a/device-sdk/src/main/java/com/maxmind/device/model/SystemSettings.kt b/device-sdk/src/main/java/com/maxmind/device/model/SystemSettings.kt index b171ec0..52d041c 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/SystemSettings.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/SystemSettings.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * System configuration settings. */ @Serializable -public data class SystemSettings( +internal data class SystemSettings( @SerialName("screen_timeout") val screenTimeout: Int? = null, @SerialName("development_settings_enabled") diff --git a/device-sdk/src/main/java/com/maxmind/device/model/TelephonyInfo.kt b/device-sdk/src/main/java/com/maxmind/device/model/TelephonyInfo.kt index a07cade..0745a28 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/TelephonyInfo.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/TelephonyInfo.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable * Telephony context information from TelephonyManager. */ @Serializable -public data class TelephonyInfo( +internal data class TelephonyInfo( @SerialName("network_operator_name") val networkOperatorName: String? = null, @SerialName("sim_state") diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index f5b08dc..c042fce 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -3,7 +3,6 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.serialization) alias(libs.plugins.detekt) alias(libs.plugins.ktlint) } @@ -80,10 +79,8 @@ dependencies { // Kotlin implementation(libs.kotlin.stdlib) - implementation(libs.kotlin.reflect) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) - implementation(libs.kotlinx.serialization.json) // AndroidX implementation(libs.androidx.core.ktx) diff --git a/sample/src/main/java/com/maxmind/device/sample/MainActivity.kt b/sample/src/main/java/com/maxmind/device/sample/MainActivity.kt index e94c0c5..3185c64 100644 --- a/sample/src/main/java/com/maxmind/device/sample/MainActivity.kt +++ b/sample/src/main/java/com/maxmind/device/sample/MainActivity.kt @@ -1,11 +1,7 @@ package com.maxmind.device.sample -import android.graphics.Typeface import android.os.Bundle import android.util.Log -import android.util.TypedValue -import android.view.View -import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar @@ -13,10 +9,6 @@ import com.maxmind.device.DeviceTracker import com.maxmind.device.config.SdkConfig import com.maxmind.device.sample.databinding.ActivityMainBinding import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.serializer -import kotlin.reflect.full.memberProperties /** * Main activity demonstrating the MaxMind Device Tracker usage. @@ -24,7 +16,6 @@ import kotlin.reflect.full.memberProperties class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private var logText = StringBuilder() - private val json = Json { prettyPrint = true } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -41,10 +32,6 @@ class MainActivity : AppCompatActivity() { initializeSdk() } - binding.btnCollect.setOnClickListener { - collectDeviceData() - } - binding.btnSend.setOnClickListener { sendDeviceData() } @@ -53,8 +40,7 @@ class MainActivity : AppCompatActivity() { clearLog() } - // Disable collect and send buttons until SDK is initialized - binding.btnCollect.isEnabled = false + // Disable send button until SDK is initialized binding.btnSend.isEnabled = false } @@ -92,7 +78,6 @@ class MainActivity : AppCompatActivity() { DeviceTracker.initialize(this, config) appendLog("✓ SDK initialized successfully") - binding.btnCollect.isEnabled = true binding.btnSend.isEnabled = true showMessage("SDK initialized successfully") } catch (e: Exception) { @@ -103,103 +88,6 @@ class MainActivity : AppCompatActivity() { } } - @Suppress("TooGenericExceptionCaught", "NestedBlockDepth") - private fun collectDeviceData() { - try { - val sdk = DeviceTracker.getInstance() - val deviceData = sdk.collectDeviceData() - - appendLog("📱 Device Data Collected:") - appendLog(" Manufacturer: ${deviceData.build.manufacturer}") - appendLog(" Model: ${deviceData.build.model}") - appendLog(" Brand: ${deviceData.build.brand}") - appendLog(" OS Version: ${deviceData.build.osVersion}") - appendLog(" SDK Version: ${deviceData.build.sdkVersion}") - val display = deviceData.display - appendLog(" Screen: ${display.widthPixels}x${display.heightPixels} (${display.densityDpi}dpi)") - appendLog(" Timestamp: ${deviceData.deviceTime}") - appendLog("") - appendLog("🔑 IDs:") - appendLog(" Stored ID: ${deviceData.storedID.id ?: "(none)"}") - appendLog(" MediaDRM ID: ${deviceData.deviceIDs.mediaDrmID ?: "(none)"}") - appendLog(" Android ID: ${deviceData.deviceIDs.androidID ?: "(none)"}") - appendLog("") - - // Dynamically add collapsible sections for each property - deviceData::class.memberProperties.forEach { prop -> - val value = prop.getter.call(deviceData) - if (value != null) { - @Suppress("SwallowedException") - val content = - try { - json.encodeToString(serializer(prop.returnType), value) - } catch (e: Exception) { - value.toString() - } - addCollapsibleSection(prop.name, content) - } - } - - // Scroll to top to show summary first - binding.scrollView.post { - binding.scrollView.fullScroll(android.view.View.FOCUS_UP) - } - - showMessage("Device data collected") - } catch (e: Exception) { - val errorMsg = "Failed to collect data: ${e.message}" - appendLog("✗ $errorMsg") - Log.e(TAG, errorMsg, e) - showMessage(errorMsg) - } - } - - private fun addCollapsibleSection( - title: String, - content: String, - ) { - val header = - TextView(this).apply { - text = "▶ $title" - setTypeface(typeface, Typeface.BOLD) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) - setPadding(0, dpToPx(12), 0, dpToPx(4)) - setTextColor(getColor(R.color.section_header)) - } - - val contentView = - TextView(this).apply { - text = content - setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) - typeface = Typeface.MONOSPACE - setPadding(dpToPx(16), dpToPx(4), 0, dpToPx(12)) - setTextColor(getColor(R.color.section_content)) - setBackgroundColor(getColor(R.color.surface)) - visibility = View.GONE - } - - header.setOnClickListener { - if (contentView.visibility == View.GONE) { - contentView.visibility = View.VISIBLE - header.text = "▼ $title" - } else { - contentView.visibility = View.GONE - header.text = "▶ $title" - } - } - - binding.logContainer.addView(header) - binding.logContainer.addView(contentView) - } - - private fun dpToPx(dp: Int): Int = - TypedValue - .applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - dp.toFloat(), - resources.displayMetrics, - ).toInt() - @Suppress("TooGenericExceptionCaught") private fun sendDeviceData() { try { @@ -242,11 +130,6 @@ class MainActivity : AppCompatActivity() { private fun clearLog() { logText.clear() binding.tvLog.text = "" - // Remove all views except the tvLog TextView - val childCount = binding.logContainer.childCount - if (childCount > 1) { - binding.logContainer.removeViews(1, childCount - 1) - } appendLog("Log cleared.") } diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml index 6c9f6df..d1656f8 100644 --- a/sample/src/main/res/layout/activity_main.xml +++ b/sample/src/main/res/layout/activity_main.xml @@ -52,16 +52,6 @@ app:icon="@android:drawable/ic_menu_preferences" app:iconGravity="start" /> - - MaxMind Device SDK Sample Initialize SDK - Collect Device Data Send Data - SDK Status - SDK not initialized - SDK initialized - Collecting device data... - Sending data... - Data sent successfully! - Error: %s - Device Information Clear Log From 29423373156f96bc2e2fe711cdbaf5a575821cd1 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 07:16:44 -0800 Subject: [PATCH 04/15] Validate tracking token before persisting to storage Construct TrackingResult (which validates via require(isNotBlank)) before calling storedIDStorage.save(). Previously a blank token would be persisted and then fail validation. Co-Authored-By: Claude Opus 4.6 --- .../java/com/maxmind/device/DeviceTracker.kt | 3 +- .../com/maxmind/device/DeviceTrackerTest.kt | 4 ++ .../device/model/TrackingResultTest.kt | 41 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 device-sdk/src/test/java/com/maxmind/device/model/TrackingResultTest.kt diff --git a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt index ac06769..1b009c8 100644 --- a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt +++ b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt @@ -86,6 +86,7 @@ public class DeviceTracker private constructor( val token = response.storedID ?: error("Server response missing tracking token") + val result = TrackingResult(trackingToken = token) try { storedIDStorage.save(token) if (config.enableLogging) { @@ -100,7 +101,7 @@ public class DeviceTracker private constructor( Log.e(TAG, "Failed to save stored ID to local storage", e) } } - TrackingResult(trackingToken = token) + result } /** diff --git a/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt b/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt index ab69580..d6d8318 100644 --- a/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt +++ b/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt @@ -193,6 +193,7 @@ internal class DeviceTrackerTest { runTest { val tracker = createTrackerWithMocks() val mockApiClient = getField(tracker, "apiClient") + val mockStorage = getField(tracker, "storedIDStorage") val response = ServerResponse(storedID = null) coEvery { mockApiClient.sendDeviceData(any()) } returns Result.success(response) @@ -202,6 +203,7 @@ internal class DeviceTrackerTest { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() is IllegalStateException) assertEquals("Server response missing tracking token", result.exceptionOrNull()?.message) + verify(exactly = 0) { mockStorage.save(any()) } } @Test @@ -244,6 +246,7 @@ internal class DeviceTrackerTest { runTest { val tracker = createTrackerWithMocks() val mockApiClient = getField(tracker, "apiClient") + val mockStorage = getField(tracker, "storedIDStorage") val response = ServerResponse(storedID = "", ipVersion = 6) coEvery { mockApiClient.sendDeviceData(any()) } returns Result.success(response) @@ -252,6 +255,7 @@ internal class DeviceTrackerTest { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() is IllegalArgumentException) + verify(exactly = 0) { mockStorage.save(any()) } } /** diff --git a/device-sdk/src/test/java/com/maxmind/device/model/TrackingResultTest.kt b/device-sdk/src/test/java/com/maxmind/device/model/TrackingResultTest.kt new file mode 100644 index 0000000..145404d --- /dev/null +++ b/device-sdk/src/test/java/com/maxmind/device/model/TrackingResultTest.kt @@ -0,0 +1,41 @@ +package com.maxmind.device.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +internal class TrackingResultTest { + @Test + internal fun `valid token creates TrackingResult`() { + val result = TrackingResult(trackingToken = "abc123:hmac456") + + assertEquals("abc123:hmac456", result.trackingToken) + } + + @Test + internal fun `empty string throws IllegalArgumentException`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + TrackingResult(trackingToken = "") + } + + assertEquals("Tracking token must not be blank", exception.message) + } + + @Test + internal fun `blank whitespace-only string throws IllegalArgumentException`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + TrackingResult(trackingToken = " ") + } + + assertEquals("Tracking token must not be blank", exception.message) + } + + @Test + internal fun `toString redacts tracking token`() { + val result = TrackingResult(trackingToken = "abc123:hmac456") + + assertEquals("TrackingResult(trackingToken=)", result.toString()) + } +} From 4c80755f099b7ba00af2c1844506f4c5fb2aa114 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 07:17:05 -0800 Subject: [PATCH 05/15] Protect auto-collection loop from uncaught exceptions collectAndSend() calls collectDeviceData() which can throw before returning a Result. An uncaught exception kills the coroutine and stops automatic collection permanently. Wrap in try/catch while letting CancellationException propagate for structured concurrency. Co-Authored-By: Claude Opus 4.6 --- .../java/com/maxmind/device/DeviceTracker.kt | 15 +++++++++++---- .../com/maxmind/device/DeviceTrackerTest.kt | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt index 1b009c8..12299bb 100644 --- a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt +++ b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt @@ -111,10 +111,17 @@ public class DeviceTracker private constructor( * * @return [Result] containing the [TrackingResult] with tracking token, or failure */ - public suspend fun collectAndSend(): Result { - val deviceData = collectDeviceData() - return sendDeviceData(deviceData) - } + public suspend fun collectAndSend(): Result = + 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. diff --git a/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt b/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt index d6d8318..e7606f8 100644 --- a/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt +++ b/device-sdk/src/test/java/com/maxmind/device/DeviceTrackerTest.kt @@ -1,6 +1,7 @@ package com.maxmind.device import android.content.Context +import com.maxmind.device.collector.DeviceDataCollector import com.maxmind.device.config.SdkConfig import com.maxmind.device.model.ServerResponse import com.maxmind.device.model.TrackingResult @@ -258,6 +259,23 @@ internal class DeviceTrackerTest { verify(exactly = 0) { mockStorage.save(any()) } } + @Test + @Order(15) + internal fun `15 collectAndSend wraps collectDeviceData exception in Result failure`() = + runTest { + val tracker = createTrackerWithMocks() + val mockCollector = mockk() + setField(tracker, "deviceDataCollector", mockCollector) + + every { mockCollector.collect() } throws RuntimeException("sensor failure") + + val result = tracker.collectAndSend() + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is RuntimeException) + assertEquals("sensor failure", result.exceptionOrNull()?.message) + } + /** * Creates a DeviceTracker with mock apiClient and storedIDStorage injected via reflection. */ From a64f64e202ce170d697ca96777237c36dc3fb9ec Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 07:17:36 -0800 Subject: [PATCH 06/15] Remove broken README section and update examples to show token usage - Remove "Manual Data Collection" section since collectDeviceData() is now internal - Replace Log.d() with sendToBackend() in examples to guide users toward passing the token to their backend for minFraud integration Co-Authored-By: Claude Opus 4.6 --- README.md | 44 +++++++++++-------- .../java/com/maxmind/device/DeviceTracker.kt | 2 +- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7daf8f8..8c7c577 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,8 @@ class MyApplication : Application() { lifecycleScope.launch { DeviceTracker.getInstance().collectAndSend() .onSuccess { trackingResult -> - Log.d("SDK", "Tracking token: ${trackingResult.trackingToken}") + // Pass the token to your backend for minFraud integration + sendToBackend(trackingResult.trackingToken) } .onFailure { error -> Log.e("SDK", "Failed to send data", error) @@ -72,7 +73,7 @@ lifecycleScope.launch { ```kotlin DeviceTracker.getInstance().collectAndSend { result -> result.onSuccess { trackingResult -> - Log.d("SDK", "Tracking token: ${trackingResult.trackingToken}") + sendToBackend(trackingResult.trackingToken) }.onFailure { error -> Log.e("SDK", "Failed to send data", error) } @@ -81,28 +82,33 @@ DeviceTracker.getInstance().collectAndSend { result -> #### Java Example -```java -DeviceTracker.getInstance().collectAndSend(result -> { - if (result.isSuccess()) { - TrackingResult trackingResult = result.getOrThrow(); - Log.d("SDK", "Tracking token: " + trackingResult.getTrackingToken()); - } else { - Throwable error = result.exceptionOrNull(); - Log.e("SDK", "Failed to send data", error); +Kotlin's `Result` 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, + onFailure: java.util.function.Consumer, +) { + tracker.collectAndSend { result -> + result.onSuccess { onSuccess.accept(it.trackingToken) } + .onFailure { onFailure.accept(it) } } -}); +} ``` -### 3. Manual Data Collection - -Collect device data without sending: - -```kotlin -val deviceData = DeviceTracker.getInstance().collectDeviceData() -println("Device: ${deviceData.build.manufacturer} ${deviceData.build.model}") +```java +// Java caller +collectAndSend( + DeviceTracker.getInstance(), + token -> sendToBackend(token), + error -> Log.e("SDK", "Failed to send data", error) +); ``` -### 4. Linking Device Data to minFraud Transactions +### 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: diff --git a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt index 12299bb..c636457 100644 --- a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt +++ b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt @@ -35,7 +35,7 @@ import kotlin.coroutines.cancellation.CancellationException * // Collect and send device data * DeviceTracker.getInstance().collectAndSend { result -> * result.onSuccess { trackingResult -> - * Log.d("SDK", "Tracking token: ${trackingResult.trackingToken}") + * sendToBackend(trackingResult.trackingToken) * }.onFailure { error -> * Log.e("SDK", "Failed to send data", error) * } From f01a8fa2b26358b06eaeab66a7df5b70a422518a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 07:17:57 -0800 Subject: [PATCH 07/15] Narrow ProGuard consumer rules to public API only Only keep TrackingResult (the sole public model class) in consumer rules. Internal model classes like DeviceData and ServerResponse can now be shrunk by R8 in consumer apps. Serialization rules remain broad since internal classes still need them at runtime. Co-Authored-By: Claude Opus 4.6 --- device-sdk/consumer-rules.pro | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/device-sdk/consumer-rules.pro b/device-sdk/consumer-rules.pro index e0ca32a..58ef91c 100644 --- a/device-sdk/consumer-rules.pro +++ b/device-sdk/consumer-rules.pro @@ -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 { *; } @@ -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(...); From 8becada7d913401d3ce782486bbcb4fd75c6f3ac Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 07:18:11 -0800 Subject: [PATCH 08/15] Remove orphaned section_header and section_content colors These color resources are unused in the sample app. Co-Authored-By: Claude Opus 4.6 --- sample/src/main/res/values/colors.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sample/src/main/res/values/colors.xml b/sample/src/main/res/values/colors.xml index d009ce1..0738bb2 100644 --- a/sample/src/main/res/values/colors.xml +++ b/sample/src/main/res/values/colors.xml @@ -13,10 +13,6 @@ #8A000000 #FFFFFFFF - - #FF00796B - #FF37474F - #FFFAFAFA #FFFFFFFF From abcd95c49dff4d8a3eb1f9aba9e20582acfbd5bd Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 07:44:24 -0800 Subject: [PATCH 09/15] Re-throw CancellationException in DeviceApiClient.sendToUrl The catch block `catch (e: Exception)` swallowed CancellationException, breaking structured concurrency. When shutdown() cancels the coroutine scope, in-flight HTTP calls would not propagate cancellation correctly. This applies the same re-throw pattern already used at both sites in DeviceTracker.kt. Co-Authored-By: Claude Opus 4.6 --- .../main/java/com/maxmind/device/network/DeviceApiClient.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt b/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt index 17e27f4..9359162 100644 --- a/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt +++ b/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt @@ -21,6 +21,7 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put +import kotlin.coroutines.cancellation.CancellationException /** * HTTP client for communicating with MaxMind device API. @@ -148,6 +149,8 @@ internal class DeviceApiClient( ApiException("Server returned ${response.status.value}: ${response.status.description}"), ) } + } catch (e: CancellationException) { + throw e } catch ( @Suppress("TooGenericExceptionCaught") e: Exception, ) { From 8410a24c925641732be15c7fee75bf4de31b1854 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 07:44:57 -0800 Subject: [PATCH 10/15] Override toString on TrackingResult to redact tracking token The data class default toString() would expose the full tracking token in logs, crash reports, and debug output. Override it with a redacted version to prevent accidental token leakage. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/com/maxmind/device/model/TrackingResult.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/device-sdk/src/main/java/com/maxmind/device/model/TrackingResult.kt b/device-sdk/src/main/java/com/maxmind/device/model/TrackingResult.kt index f80c49a..6eadbbf 100644 --- a/device-sdk/src/main/java/com/maxmind/device/model/TrackingResult.kt +++ b/device-sdk/src/main/java/com/maxmind/device/model/TrackingResult.kt @@ -13,4 +13,6 @@ public data class TrackingResult( init { require(trackingToken.isNotBlank()) { "Tracking token must not be blank" } } + + override fun toString(): String = "TrackingResult(trackingToken=)" } From 977e95e9578f9f1aa1d8048674e059082a2e1cfb Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 07:46:43 -0800 Subject: [PATCH 11/15] Add debug logging for IPv4 fire-and-forget request The IPv4 request result was silently discarded, making it impossible to diagnose persistent IPv4 endpoint failures. Log success/failure at debug level when enableLogging is true. Co-Authored-By: Claude Opus 4.6 --- .../java/com/maxmind/device/network/DeviceApiClient.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt b/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt index 9359162..b18c1fb 100644 --- a/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt +++ b/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt @@ -1,5 +1,6 @@ package com.maxmind.device.network +import android.util.Log import com.maxmind.device.config.SdkConfig import com.maxmind.device.model.DeviceData import com.maxmind.device.model.ServerResponse @@ -110,7 +111,13 @@ internal class DeviceApiClient( ) // Send to IPv4 but don't fail the overall operation if it fails // The stored_id from IPv6 is already valid - sendToUrl(dataWithDuration, ipv4Url) + val ipv4Result = sendToUrl(dataWithDuration, ipv4Url) + if (config.enableLogging) { + ipv4Result.fold( + onSuccess = { Log.d(TAG, "IPv4 device data sent successfully") }, + onFailure = { e -> Log.d(TAG, "IPv4 device data send failed (non-fatal)", e) }, + ) + } } // Return the IPv6 response (which has the stored_id) From bc933e36e176f99097198b786e670bbf639d7bb3 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 08:02:30 -0800 Subject: [PATCH 12/15] Fix CHANGELOG and CLAUDE.md for API surface changes - Remove sendDeviceData() from CHANGELOG first bullet since it is no longer part of the public API - Update CLAUDE.md DeviceData section to reflect internal visibility - Remove "Keeps Ktor classes" from CLAUDE.md ProGuard section (those rules are in proguard-rules.pro, not consumer-rules.pro) Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 7 +++---- CLAUDE.md | 9 +++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1c7c0..9b3424d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,9 @@ ## 0.2.0 (unreleased) -- **Breaking:** `collectAndSend()` and `sendDeviceData()` now return - `Result` instead of `Result`. The `TrackingResult` - contains a `trackingToken` property for use with the minFraud API's - `/device/tracking_token` field. +- **Breaking:** `collectAndSend()` now returns `Result` instead + of `Result`. 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. diff --git a/CLAUDE.md b/CLAUDE.md index 6ac1732..656c783 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,10 +155,8 @@ The SDK uses a **singleton pattern with initialization guard**: **DeviceData** (`model/DeviceData.kt`): -- Marked with `@Serializable` for kotlinx.serialization -- All fields are public for Java compatibility -- Immutable data class -- Optional `storedID` field (defaults to empty `StoredID`) +- Internal data class marked with `@Serializable` for kotlinx.serialization +- Immutable with default values for optional fields ## Java Compatibility Strategy @@ -217,9 +215,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 From 1a5452e9295b575bcc342c5fb965f562fb8123ac Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 08:06:05 -0800 Subject: [PATCH 13/15] Document stored ID vs tracking token terminology in CLAUDE.md Add a terminology section clarifying the distinction: "stored ID" is the server-generated value used internally throughout the SDK, while "tracking token" is the public-facing name exposed to consumers via TrackingResult. They are currently the same value but the abstraction allows them to diverge. Also fix the dual-request flow description which incorrectly said "tracking token". Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 656c783..7d57dbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,11 +146,27 @@ The SDK uses a **singleton pattern with initialization guard**: 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 tracking token 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`): From 7b533ed3801576158625d960e705e679767be6e7 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 12:44:28 -0800 Subject: [PATCH 14/15] Replace mapCatching with explicit handling in sendDeviceData mapCatching internally catches Throwable (including CancellationException), which breaks structured concurrency cancellation. Replace with getOrElse/early-return so only CancellationException propagates and all other failures are wrapped in Result.failure(). Co-Authored-By: Claude Opus 4.6 --- .../java/com/maxmind/device/DeviceTracker.kt | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt index c636457..2b3216a 100644 --- a/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt +++ b/device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt @@ -81,28 +81,35 @@ public class DeviceTracker private constructor( * @param deviceData The device data to send * @return [Result] containing the [TrackingResult] with tracking token, or failure */ - internal suspend fun sendDeviceData(deviceData: DeviceData): Result = - apiClient.sendDeviceData(deviceData).mapCatching { response -> - val token = - response.storedID - ?: error("Server response missing tracking token") - val result = TrackingResult(trackingToken = token) + @Suppress("ReturnCount") + internal suspend fun sendDeviceData(deviceData: DeviceData): Result { + 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 { - 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) - } + 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) } - result } + return Result.success(result) + } /** * Collects device data and sends it to MaxMind servers in one operation. From f6ffce388cf0855ea9718957501c3cb5d59927ba Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 27 Feb 2026 12:44:39 -0800 Subject: [PATCH 15/15] Remove prettyPrint from production JSON serialization prettyPrint adds unnecessary whitespace to every API request payload, wasting bandwidth on mobile devices. It defaults to false, which is the appropriate setting for production use. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/com/maxmind/device/network/DeviceApiClient.kt | 1 - .../test/java/com/maxmind/device/network/DeviceApiClientTest.kt | 2 -- 2 files changed, 3 deletions(-) diff --git a/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt b/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt index b18c1fb..cfb67b1 100644 --- a/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt +++ b/device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt @@ -43,7 +43,6 @@ internal class DeviceApiClient( ) { private val json = Json { - prettyPrint = true isLenient = true ignoreUnknownKeys = true } diff --git a/device-sdk/src/test/java/com/maxmind/device/network/DeviceApiClientTest.kt b/device-sdk/src/test/java/com/maxmind/device/network/DeviceApiClientTest.kt index 58581bf..1aa8187 100644 --- a/device-sdk/src/test/java/com/maxmind/device/network/DeviceApiClientTest.kt +++ b/device-sdk/src/test/java/com/maxmind/device/network/DeviceApiClientTest.kt @@ -434,7 +434,6 @@ internal class DeviceApiClientTest { install(ContentNegotiation) { json( Json { - prettyPrint = true isLenient = true ignoreUnknownKeys = true }, @@ -458,7 +457,6 @@ internal class DeviceApiClientTest { install(ContentNegotiation) { json( Json { - prettyPrint = true isLenient = true ignoreUnknownKeys = true },