diff --git a/CHANGELOG.md b/CHANGELOG.md index 338386f..9b3424d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.2.0 (unreleased) + +- **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. + ## 0.1.0 (2026-01-09) - Initial release diff --git a/CLAUDE.md b/CLAUDE.md index e17d0bd..7d57dbb 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,19 +146,33 @@ 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 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 @@ -176,17 +191,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** @@ -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 diff --git a/README.md b/README.md index 75f5f0d..8c7c577 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) } @@ -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` 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 +```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 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(...); 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..2b3216a 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 -> + * sendToBackend(trackingResult.trackingToken) * }.onFailure { error -> * Log.e("SDK", "Failed to send data", error) * } @@ -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 = - 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 { + 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 { - 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. * * 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 +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) } } 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/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..6eadbbf --- /dev/null +++ b/device-sdk/src/main/java/com/maxmind/device/model/TrackingResult.kt @@ -0,0 +1,18 @@ +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" } + } + + override fun toString(): String = "TrackingResult(trackingToken=)" +} 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..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 @@ -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 @@ -21,6 +22,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. @@ -41,7 +43,6 @@ internal class DeviceApiClient( ) { private val json = Json { - prettyPrint = true isLenient = true ignoreUnknownKeys = true } @@ -75,7 +76,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) { @@ -109,7 +110,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) @@ -148,6 +155,8 @@ internal class DeviceApiClient( ApiException("Server returned ${response.status.value}: ${response.status.description}"), ) } + } catch (e: CancellationException) { + throw e } catch ( @Suppress("TooGenericExceptionCaught") e: Exception, ) { 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..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,9 +1,17 @@ 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 +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 +168,150 @@ 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 mockStorage = getField(tracker, "storedIDStorage") + + 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) + verify(exactly = 0) { mockStorage.save(any()) } + } + + @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 mockStorage = getField(tracker, "storedIDStorage") + + 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) + 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. + */ + 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/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()) + } +} 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 }, 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" 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 27722ab..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 { @@ -209,8 +97,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 -> @@ -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" /> - - #8A000000 #FFFFFFFF - - #FF00796B - #FF37474F - #FFFAFAFA #FFFFFFFF diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 9271202..0f407c8 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -2,15 +2,6 @@ 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