Skip to content

Commit a47ba6c

Browse files
oschwaldclaude
andcommitted
feat: Add server-stored ID support and rename device identifiers
This commit implements server-generated stored IDs (similar to browser cookies in the JS implementation) and renames the existing device-generated identifiers for clarity. Changes: - Rename StoredIDs → DeviceIDs for device-generated hardware identifiers (MediaDRM ID, Android ID) - Add StoredID model for server-generated IDs (format: "{uuid}:{hmac}") - Add StoredIDStorage using SharedPreferences for persistence - Add StoredIDCollector to read stored IDs during collection - Update DeviceApiClient to parse server response and return stored ID - Update DeviceTracker to save stored ID from server response - Add @SerialName annotations for snake_case JSON serialization JSON field names use snake_case for consistency with other MaxMind services: - stored_id, device_ids, media_drm_id, android_id 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 40fbe01 commit a47ba6c

File tree

14 files changed

+383
-43
lines changed

14 files changed

+383
-43
lines changed

device-sdk/src/main/java/com/maxmind/device/DeviceTracker.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.maxmind.device.collector.DeviceDataCollector
66
import com.maxmind.device.config.SdkConfig
77
import com.maxmind.device.model.DeviceData
88
import com.maxmind.device.network.DeviceApiClient
9+
import com.maxmind.device.storage.StoredIDStorage
910
import kotlinx.coroutines.CoroutineScope
1011
import kotlinx.coroutines.Dispatchers
1112
import kotlinx.coroutines.SupervisorJob
@@ -44,7 +45,8 @@ public class DeviceTracker private constructor(
4445
private val config: SdkConfig,
4546
) {
4647
private val applicationContext: Context = context.applicationContext
47-
private val deviceDataCollector = DeviceDataCollector(applicationContext)
48+
private val storedIDStorage = StoredIDStorage(applicationContext)
49+
private val deviceDataCollector = DeviceDataCollector(applicationContext, storedIDStorage)
4850
private val apiClient =
4951
DeviceApiClient(
5052
serverUrl = config.serverUrl,
@@ -78,12 +80,21 @@ public class DeviceTracker private constructor(
7880
* Sends device data to MaxMind servers.
7981
*
8082
* This is a suspending function that should be called from a coroutine.
83+
* On success, saves the server-generated stored ID for future requests.
8184
*
8285
* @param deviceData The device data to send
8386
* @return [Result] indicating success or failure
8487
*/
8588
public suspend fun sendDeviceData(deviceData: DeviceData): Result<Unit> {
86-
return apiClient.sendDeviceData(deviceData).map { Unit }
89+
return apiClient.sendDeviceData(deviceData).map { response ->
90+
// Save the stored ID from the server response
91+
response.storedID?.let { id ->
92+
storedIDStorage.save(id)
93+
if (config.enableLogging) {
94+
Log.d(TAG, "Stored ID saved from server response")
95+
}
96+
}
97+
}
8798
}
8899

89100
/**

device-sdk/src/main/java/com/maxmind/device/collector/DeviceDataCollector.kt

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import com.maxmind.device.model.DisplayInfo
1515
import com.maxmind.device.model.HardwareInfo
1616
import com.maxmind.device.model.InstallationInfo
1717
import com.maxmind.device.model.LocaleInfo
18+
import com.maxmind.device.model.StoredID
19+
import com.maxmind.device.storage.StoredIDStorage
1820
import java.util.Locale
1921
import java.util.TimeZone
2022

@@ -23,9 +25,16 @@ import java.util.TimeZone
2325
*
2426
* This class is responsible for gathering various device attributes
2527
* that are available through the Android APIs.
28+
*
29+
* @param context Application context for accessing system services
30+
* @param storedIDStorage Optional storage for server-generated stored IDs
2631
*/
27-
internal class DeviceDataCollector(private val context: Context) {
28-
private val storedIDsCollector = StoredIDsCollector(context)
32+
internal class DeviceDataCollector(
33+
private val context: Context,
34+
storedIDStorage: StoredIDStorage? = null,
35+
) {
36+
private val storedIDCollector = storedIDStorage?.let { StoredIDCollector(it) }
37+
private val deviceIDsCollector = DeviceIDsCollector(context)
2938
private val gpuCollector = GpuCollector()
3039
private val audioCollector = AudioCollector(context)
3140
private val sensorCollector = SensorCollector(context)
@@ -42,9 +51,10 @@ internal class DeviceDataCollector(private val context: Context) {
4251
*
4352
* @return [DeviceData] containing collected device information
4453
*/
45-
fun collect(): DeviceData {
46-
return DeviceData(
47-
storedIDs = storedIDsCollector.collect(),
54+
fun collect(): DeviceData =
55+
DeviceData(
56+
storedID = storedIDCollector?.collect() ?: StoredID(),
57+
deviceIDs = deviceIDsCollector.collect(),
4858
build = collectBuildInfo(),
4959
display = collectDisplayInfo(),
5060
hardware = collectHardwareInfo(),

device-sdk/src/main/java/com/maxmind/device/collector/StoredIDsCollector.kt renamed to device-sdk/src/main/java/com/maxmind/device/collector/DeviceIDsCollector.kt

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,33 @@
11
package com.maxmind.device.collector
22

3+
import android.annotation.SuppressLint
34
import android.content.Context
45
import android.media.MediaDrm
5-
import android.os.Build
66
import android.provider.Settings
77
import android.util.Base64
8-
import com.maxmind.device.model.StoredIDs
8+
import com.maxmind.device.model.DeviceIDs
99
import java.util.UUID
1010

1111
/**
12-
* Collects persistent device identifiers.
12+
* Collects device-generated persistent identifiers.
1313
*
1414
* This collector gathers hardware-backed and app-scoped identifiers
15-
* that can be used for device fingerprinting.
15+
* that can be used for device fingerprinting. These are distinct from
16+
* server-generated stored IDs.
1617
*/
17-
internal class StoredIDsCollector(private val context: Context) {
18+
internal class DeviceIDsCollector(
19+
private val context: Context,
20+
) {
1821
/**
19-
* Collects stored device identifiers.
22+
* Collects device-generated identifiers.
2023
*
21-
* @return [StoredIDs] containing available device identifiers
24+
* @return [DeviceIDs] containing available device identifiers
2225
*/
23-
fun collect(): StoredIDs {
24-
return StoredIDs(
26+
fun collect(): DeviceIDs =
27+
DeviceIDs(
2528
mediaDrmID = collectMediaDrmID(),
2629
androidID = collectAndroidID(),
2730
)
28-
}
2931

3032
/**
3133
* Collects the MediaDRM device unique ID.
@@ -35,19 +37,14 @@ internal class StoredIDsCollector(private val context: Context) {
3537
*
3638
* @return Base64-encoded device ID, or null if unavailable
3739
*/
38-
private fun collectMediaDrmID(): String? {
39-
return try {
40+
private fun collectMediaDrmID(): String? =
41+
try {
4042
val mediaDrm = MediaDrm(WIDEVINE_UUID)
4143
try {
4244
val deviceId = mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID)
4345
Base64.encodeToString(deviceId, Base64.NO_WRAP)
4446
} finally {
45-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
46-
mediaDrm.close()
47-
} else {
48-
@Suppress("DEPRECATION")
49-
mediaDrm.release()
50-
}
47+
mediaDrm.close()
5148
}
5249
} catch (
5350
@Suppress("TooGenericExceptionCaught", "SwallowedException")
@@ -56,7 +53,6 @@ internal class StoredIDsCollector(private val context: Context) {
5653
// MediaDRM may not be available on all devices (e.g., emulators, some custom ROMs)
5754
null
5855
}
59-
}
6056

6157
/**
6258
* Collects the Android ID (SSAID).
@@ -67,8 +63,9 @@ internal class StoredIDsCollector(private val context: Context) {
6763
*
6864
* @return The Android ID string, or null if unavailable
6965
*/
70-
private fun collectAndroidID(): String? {
71-
return try {
66+
@SuppressLint("HardwareIds") // Intentional for fraud detection fingerprinting
67+
private fun collectAndroidID(): String? =
68+
try {
7269
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
7370
} catch (
7471
@Suppress("TooGenericExceptionCaught", "SwallowedException")
@@ -77,7 +74,6 @@ internal class StoredIDsCollector(private val context: Context) {
7774
// Settings.Secure may throw on some custom ROMs or restricted contexts
7875
null
7976
}
80-
}
8177

8278
internal companion object {
8379
/**
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.maxmind.device.collector
2+
3+
import com.maxmind.device.model.StoredID
4+
import com.maxmind.device.storage.StoredIDStorage
5+
6+
/**
7+
* Collects the server-generated stored ID from local storage.
8+
*
9+
* This collector retrieves the stored ID that was previously received
10+
* from the server and saved to SharedPreferences.
11+
*/
12+
internal class StoredIDCollector(
13+
private val storage: StoredIDStorage,
14+
) {
15+
/**
16+
* Collects the stored ID from local storage.
17+
*
18+
* @return [StoredID] containing the ID if available, or empty StoredID if not
19+
*/
20+
fun collect(): StoredID = StoredID(id = storage.get())
21+
}

device-sdk/src/main/java/com/maxmind/device/model/DeviceData.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.maxmind.device.model
22

3+
import kotlinx.serialization.SerialName
34
import kotlinx.serialization.Serializable
45

56
/**
@@ -10,8 +11,12 @@ import kotlinx.serialization.Serializable
1011
*/
1112
@Serializable
1213
public data class DeviceData(
13-
// Identifiers
14-
val storedIDs: StoredIDs = StoredIDs(),
14+
// Server-generated stored ID (like browser cookies)
15+
@SerialName("stored_id")
16+
val storedID: StoredID = StoredID(),
17+
// Device-generated identifiers
18+
@SerialName("device_ids")
19+
val deviceIDs: DeviceIDs = DeviceIDs(),
1520
// Device info
1621
val build: BuildInfo,
1722
val display: DisplayInfo,

device-sdk/src/main/java/com/maxmind/device/model/StoredIDs.kt renamed to device-sdk/src/main/java/com/maxmind/device/model/DeviceIDs.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
package com.maxmind.device.model
22

3+
import kotlinx.serialization.SerialName
34
import kotlinx.serialization.Serializable
45

56
/**
6-
* Device identifiers that persist across sessions.
7+
* Device-generated identifiers that persist across sessions.
8+
*
9+
* These are hardware/system identifiers generated on the device itself,
10+
* distinct from server-generated stored IDs.
711
*
812
* @property mediaDrmID Hardware-backed ID from MediaDRM, persists through factory reset
913
* @property androidID App-scoped ID from Settings.Secure, persists across reinstalls
1014
*/
1115
@Serializable
12-
public data class StoredIDs(
16+
public data class DeviceIDs(
17+
@SerialName("media_drm_id")
1318
val mediaDrmID: String? = null,
19+
@SerialName("android_id")
1420
val androidID: String? = null,
1521
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.maxmind.device.model
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
/**
7+
* Response from the MaxMind device API.
8+
*
9+
* @property storedID The server-generated stored ID (format: "{uuid}:{hmac}")
10+
*/
11+
@Serializable
12+
public data class ServerResponse(
13+
@SerialName("stored_id")
14+
val storedID: String? = null,
15+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.maxmind.device.model
2+
3+
import kotlinx.serialization.Serializable
4+
5+
/**
6+
* Server-generated identifier stored locally on the device.
7+
*
8+
* This ID is generated by the MaxMind server and sent back to the client
9+
* for storage. It includes an HMAC signature to prevent tampering.
10+
* Format: "{uuid}:{hmac}"
11+
*
12+
* Similar to browser cookies/localStorage stored IDs in the JS implementation.
13+
*
14+
* @property id The stored ID string in format "{uuid}:{hmac}", or null if not yet received from server
15+
*/
16+
@Serializable
17+
public data class StoredID(
18+
val id: String? = null,
19+
)

device-sdk/src/main/java/com/maxmind/device/network/DeviceApiClient.kt

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
package com.maxmind.device.network
22

33
import com.maxmind.device.model.DeviceData
4+
import com.maxmind.device.model.ServerResponse
45
import io.ktor.client.HttpClient
6+
import io.ktor.client.call.body
57
import io.ktor.client.engine.android.Android
68
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
79
import io.ktor.client.plugins.logging.LogLevel
810
import io.ktor.client.plugins.logging.Logger
911
import io.ktor.client.plugins.logging.Logging
10-
import io.ktor.client.request.header
1112
import io.ktor.client.request.post
1213
import io.ktor.client.request.setBody
13-
import io.ktor.client.statement.HttpResponse
1414
import io.ktor.http.ContentType
1515
import io.ktor.http.contentType
16+
import io.ktor.http.isSuccess
1617
import io.ktor.serialization.kotlinx.json.json
1718
import kotlinx.serialization.json.Json
1819
import kotlinx.serialization.json.buildJsonObject
@@ -61,7 +62,7 @@ internal class DeviceApiClient(
6162
* Sends device data to the MaxMind API.
6263
*
6364
* @param deviceData The device data to send
64-
* @return [Result] containing the HTTP response or an error
65+
* @return [Result] containing the server response with stored ID, or an error
6566
*/
6667
suspend fun sendDeviceData(deviceData: DeviceData): Result<ServerResponse> {
6768
return try {
@@ -74,14 +75,27 @@ internal class DeviceApiClient(
7475
put(key, value)
7576
}
7677
}
77-
Result.success(response)
78+
79+
if (response.status.isSuccess()) {
80+
val serverResponse: ServerResponse = response.body()
81+
Result.success(serverResponse)
82+
} else {
83+
Result.failure(
84+
ApiException("Server returned ${response.status.value}: ${response.status.description}"),
85+
)
86+
}
7887
} catch (
7988
@Suppress("TooGenericExceptionCaught") e: Exception,
8089
) {
8190
Result.failure(e)
8291
}
8392
}
8493

94+
/**
95+
* Exception thrown when API request fails.
96+
*/
97+
public class ApiException(message: String) : Exception(message)
98+
8599
/**
86100
* Closes the HTTP client and releases resources.
87101
*/
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.maxmind.device.storage
2+
3+
import android.content.Context
4+
import android.content.SharedPreferences
5+
import androidx.core.content.edit
6+
7+
/**
8+
* Manages persistent storage of server-generated stored IDs.
9+
*
10+
* Uses SharedPreferences for storage, similar to how the JS implementation
11+
* uses cookies and localStorage. The stored ID is server-generated and
12+
* includes an HMAC signature for validation.
13+
*/
14+
internal class StoredIDStorage(
15+
context: Context,
16+
) {
17+
private val prefs: SharedPreferences =
18+
context.applicationContext.getSharedPreferences(
19+
PREFS_NAME,
20+
Context.MODE_PRIVATE,
21+
)
22+
23+
/**
24+
* Retrieves the stored ID.
25+
*
26+
* @return The stored ID string, or null if not set
27+
*/
28+
fun get(): String? = prefs.getString(KEY_STORED_ID, null)
29+
30+
/**
31+
* Saves a stored ID.
32+
*
33+
* @param id The stored ID to save (format: "{uuid}:{hmac}")
34+
*/
35+
fun save(id: String) {
36+
prefs.edit { putString(KEY_STORED_ID, id) }
37+
}
38+
39+
/**
40+
* Clears the stored ID.
41+
*/
42+
fun clear() {
43+
prefs.edit { remove(KEY_STORED_ID) }
44+
}
45+
46+
internal companion object {
47+
internal const val PREFS_NAME = "com.maxmind.device.storage"
48+
internal const val KEY_STORED_ID = "stored_id"
49+
}
50+
}

0 commit comments

Comments
 (0)