Skip to content

Commit d306783

Browse files
oschwaldclaude
andcommitted
feat: Implement IPv6/IPv4 dual-request flow for IP address capture
Add dual-request logic to capture both IPv6 and IPv4 addresses for devices: - First sends to IPv6 endpoint (d-ipv6.mmapiws.com) - If response has ip_version=6, also sends to IPv4 endpoint (d-ipv4.mmapiws.com) - Custom server URL bypasses dual-request and sends to single endpoint Changes: - SdkConfig: Replace serverUrl with customServerUrl, add useDefaultServers, add DEFAULT_IPV6_HOST, DEFAULT_IPV4_HOST, ENDPOINT_PATH constants - ServerResponse: Add ipVersion field for IP version detection - DeviceApiClient: Refactor to accept SdkConfig, implement sendWithDualRequest() - DeviceTracker: Update to use SdkConfig-based DeviceApiClient - Update tests for new behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 96a3392 commit d306783

File tree

7 files changed

+185
-59
lines changed

7 files changed

+185
-59
lines changed

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

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,7 @@ public class DeviceTracker private constructor(
4747
private val applicationContext: Context = context.applicationContext
4848
private val storedIDStorage = StoredIDStorage(applicationContext)
4949
private val deviceDataCollector = DeviceDataCollector(applicationContext, storedIDStorage)
50-
private val apiClient =
51-
DeviceApiClient(
52-
serverUrl = config.serverUrl,
53-
accountID = config.accountID,
54-
enableLogging = config.enableLogging,
55-
)
50+
private val apiClient = DeviceApiClient(config)
5651

5752
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
5853

@@ -72,9 +67,7 @@ public class DeviceTracker private constructor(
7267
*
7368
* @return [DeviceData] containing collected device information
7469
*/
75-
public fun collectDeviceData(): DeviceData {
76-
return deviceDataCollector.collect()
77-
}
70+
public fun collectDeviceData(): DeviceData = deviceDataCollector.collect()
7871

7972
/**
8073
* Sends device data to MaxMind servers.
@@ -85,8 +78,8 @@ public class DeviceTracker private constructor(
8578
* @param deviceData The device data to send
8679
* @return [Result] indicating success or failure
8780
*/
88-
public suspend fun sendDeviceData(deviceData: DeviceData): Result<Unit> {
89-
return apiClient.sendDeviceData(deviceData).map { response ->
81+
public suspend fun sendDeviceData(deviceData: DeviceData): Result<Unit> =
82+
apiClient.sendDeviceData(deviceData).map { response ->
9083
// Save the stored ID from the server response
9184
response.storedID?.let { id ->
9285
storedIDStorage.save(id)
@@ -95,7 +88,6 @@ public class DeviceTracker private constructor(
9588
}
9689
}
9790
}
98-
}
9991

10092
/**
10193
* Collects device data and sends it to MaxMind servers in one operation.
@@ -195,10 +187,9 @@ public class DeviceTracker private constructor(
195187
* @throws IllegalStateException if SDK is not initialized
196188
*/
197189
@JvmStatic
198-
public fun getInstance(): DeviceTracker {
199-
return instance
190+
public fun getInstance(): DeviceTracker =
191+
instance
200192
?: error("SDK not initialized. Call initialize() first.")
201-
}
202193

203194
/**
204195
* Checks if the SDK is initialized.

device-sdk/src/main/java/com/maxmind/device/config/SdkConfig.kt

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,41 +6,54 @@ package com.maxmind.device.config
66
* Use [SdkConfig.Builder] to create instances of this class.
77
*
88
* @property accountID MaxMind account ID for identifying the account
9-
* @property serverUrl Base URL for the MaxMind API endpoint
9+
* @property customServerUrl Custom server URL (null = use default IPv6/IPv4 dual-request)
1010
* @property enableLogging Enable debug logging for the SDK
1111
* @property collectionIntervalMs Interval in milliseconds for automatic data collection (0 = disabled)
1212
*/
1313
public data class SdkConfig internal constructor(
1414
val accountID: Int,
15-
val serverUrl: String = DEFAULT_SERVER_URL,
15+
val customServerUrl: String? = null,
1616
val enableLogging: Boolean = false,
1717
val collectionIntervalMs: Long = 0,
1818
) {
19+
/**
20+
* Whether to use the default dual-request flow (IPv6 then IPv4).
21+
* Returns true when no custom server URL is set.
22+
*/
23+
val useDefaultServers: Boolean
24+
get() = customServerUrl == null
25+
1926
/**
2027
* Builder for creating [SdkConfig] instances.
2128
*
2229
* Example usage:
2330
* ```
2431
* val config = SdkConfig.Builder(123456)
25-
* .serverUrl("https://custom.maxmind.com/api")
32+
* .serverUrl("https://custom.maxmind.com/api") // Optional: override default servers
2633
* .enableLogging(true)
2734
* .collectionInterval(60_000) // Collect every 60 seconds
2835
* .build()
2936
* ```
3037
*/
31-
public class Builder(private val accountID: Int) {
32-
private var serverUrl: String = DEFAULT_SERVER_URL
38+
public class Builder(
39+
private val accountID: Int,
40+
) {
41+
private var customServerUrl: String? = null
3342
private var enableLogging: Boolean = false
3443
private var collectionIntervalMs: Long = 0
3544

3645
/**
37-
* Set the server URL for the MaxMind API endpoint.
46+
* Set a custom server URL for the MaxMind API endpoint.
3847
*
39-
* @param url Base URL (e.g., "https://api.maxmind.com/device")
48+
* If not set, the SDK will use the default dual-request flow:
49+
* 1. First request to IPv6 server (d-ipv6.mmapiws.com)
50+
* 2. If IPv6 succeeds, also request to IPv4 server (d-ipv4.mmapiws.com)
51+
*
52+
* @param url Custom server URL (e.g., "https://custom.example.com")
4053
*/
4154
public fun serverUrl(url: String): Builder =
4255
apply {
43-
this.serverUrl = url
56+
this.customServerUrl = url
4457
}
4558

4659
/**
@@ -69,21 +82,27 @@ public data class SdkConfig internal constructor(
6982
*/
7083
public fun build(): SdkConfig {
7184
require(accountID > 0) { "Account ID must be positive" }
72-
require(serverUrl.isNotBlank()) { "Server URL cannot be blank" }
85+
customServerUrl?.let {
86+
require(it.isNotBlank()) { "Server URL cannot be blank" }
87+
}
7388

7489
return SdkConfig(
7590
accountID = accountID,
76-
serverUrl = serverUrl,
91+
customServerUrl = customServerUrl,
7792
enableLogging = enableLogging,
7893
collectionIntervalMs = collectionIntervalMs,
7994
)
8095
}
8196
}
8297

8398
public companion object {
84-
/**
85-
* Default MaxMind server URL.
86-
*/
87-
public const val DEFAULT_SERVER_URL: String = "https://device-api.maxmind.com/v1"
99+
/** Default IPv6 server host */
100+
public const val DEFAULT_IPV6_HOST: String = "d-ipv6.mmapiws.com"
101+
102+
/** Default IPv4 server host */
103+
public const val DEFAULT_IPV4_HOST: String = "d-ipv4.mmapiws.com"
104+
105+
/** API endpoint path */
106+
public const val ENDPOINT_PATH: String = "/device/android"
88107
}
89108
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import kotlinx.serialization.Serializable
77
* Response from the MaxMind device API.
88
*
99
* @property storedID The server-generated stored ID (format: "{uuid}:{hmac}")
10+
* @property ipVersion The IP version used for the request (4 or 6)
1011
*/
1112
@Serializable
1213
public data class ServerResponse(
1314
@SerialName("stored_id")
1415
val storedID: String? = null,
16+
@SerialName("ip_version")
17+
val ipVersion: Int? = null,
1518
)

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

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

3+
import com.maxmind.device.config.SdkConfig
34
import com.maxmind.device.model.DeviceData
45
import com.maxmind.device.model.ServerResponse
56
import io.ktor.client.HttpClient
@@ -25,17 +26,17 @@ import kotlinx.serialization.json.put
2526
* HTTP client for communicating with MaxMind device API.
2627
*
2728
* This class handles the network communication for sending device data
28-
* to MaxMind servers.
29+
* to MaxMind servers. By default, it uses a dual-request flow:
30+
* 1. Send to IPv6 endpoint first
31+
* 2. If IPv6 succeeds and returns ip_version=6, also send to IPv4 endpoint
2932
*
30-
* @param serverUrl Base URL for the MaxMind device API
31-
* @param accountID MaxMind account ID
32-
* @param enableLogging Whether to enable HTTP logging (default: false)
33+
* This ensures both IP addresses are captured for the device.
34+
*
35+
* @param config SDK configuration
3336
* @param httpClient Optional HttpClient for testing (default: creates Android engine client)
3437
*/
3538
internal class DeviceApiClient(
36-
private val serverUrl: String,
37-
private val accountID: Int,
38-
enableLogging: Boolean = false,
39+
private val config: SdkConfig,
3940
httpClient: HttpClient? = null,
4041
) {
4142
private val json =
@@ -51,7 +52,7 @@ internal class DeviceApiClient(
5152
json(this@DeviceApiClient.json)
5253
}
5354

54-
if (enableLogging) {
55+
if (config.enableLogging) {
5556
install(Logging) {
5657
logger =
5758
object : Logger {
@@ -65,25 +66,70 @@ internal class DeviceApiClient(
6566
}
6667

6768
/**
68-
* Sends device data to the MaxMind API.
69+
* Sends device data to the MaxMind API using the dual-request flow.
70+
*
71+
* If using default servers (no custom URL set):
72+
* 1. First sends to IPv6 endpoint
73+
* 2. If IPv6 response indicates ip_version=6, also sends to IPv4 endpoint
74+
*
75+
* If a custom server URL is set, sends only to that URL.
6976
*
7077
* @param deviceData The device data to send
7178
* @return [Result] containing the server response with stored ID, or an error
7279
*/
7380
suspend fun sendDeviceData(deviceData: DeviceData): Result<ServerResponse> =
81+
if (config.useDefaultServers) {
82+
sendWithDualRequest(deviceData)
83+
} else {
84+
sendToUrl(deviceData, config.customServerUrl!! + SdkConfig.ENDPOINT_PATH)
85+
}
86+
87+
/**
88+
* Sends device data using the dual-request flow (IPv6 first, then IPv4 if needed).
89+
*/
90+
private suspend fun sendWithDualRequest(deviceData: DeviceData): Result<ServerResponse> {
91+
// First, try IPv6
92+
val ipv6Url = "https://${SdkConfig.DEFAULT_IPV6_HOST}${SdkConfig.ENDPOINT_PATH}"
93+
val ipv6Result = sendToUrl(deviceData, ipv6Url)
94+
95+
if (ipv6Result.isFailure) {
96+
return ipv6Result
97+
}
98+
99+
val ipv6Response = ipv6Result.getOrNull()!!
100+
101+
// If we got an IPv6 response, also send to IPv4 to capture that IP
102+
if (ipv6Response.ipVersion == IPV6) {
103+
val ipv4Url = "https://${SdkConfig.DEFAULT_IPV4_HOST}${SdkConfig.ENDPOINT_PATH}"
104+
// Send to IPv4 but don't fail the overall operation if it fails
105+
// The stored_id from IPv6 is already valid
106+
sendToUrl(deviceData, ipv4Url)
107+
}
108+
109+
// Return the IPv6 response (which has the stored_id)
110+
return ipv6Result
111+
}
112+
113+
/**
114+
* Sends device data to a specific URL.
115+
*/
116+
internal suspend fun sendToUrl(
117+
deviceData: DeviceData,
118+
url: String,
119+
): Result<ServerResponse> =
74120
try {
75121
// Build request body with account_id at top level, merged with device data
76122
val requestBody =
77123
buildJsonObject {
78-
put("account_id", accountID)
124+
put("account_id", config.accountID)
79125
// Merge all DeviceData fields into the request
80126
json.encodeToJsonElement(deviceData).jsonObject.forEach { (key, value) ->
81127
put(key, value)
82128
}
83129
}
84130

85131
val response =
86-
client.post("$serverUrl/android/device") {
132+
client.post(url) {
87133
contentType(ContentType.Application.Json)
88134
setBody(requestBody)
89135
}
@@ -118,5 +164,6 @@ internal class DeviceApiClient(
118164

119165
private companion object {
120166
private const val TAG = "DeviceApiClient"
167+
private const val IPV6 = 6
121168
}
122169
}

0 commit comments

Comments
 (0)