From 0759d22840db639aecf6bb4aff81325bb9d67f8c Mon Sep 17 00:00:00 2001 From: Riabkov Date: Fri, 3 Jul 2026 14:17:35 +0300 Subject: [PATCH 1/2] - Enrich OptableSDK.tageting with hids - Add hids, bundle, ver, ua, id5_signature to targeting API - Add AppInfoHolder.kt for bundle and appVersion - Add id5_signature to LocalStorage.kt --- README.md | 58 +++++++++ .../main/java/co/optable/sdk/OptableSDK.kt | 34 ++++- .../java/co/optable/sdk/core/AppInfoHolder.kt | 44 +++++++ .../java/co/optable/sdk/core/LocalStorage.kt | 11 ++ .../main/java/co/optable/sdk/core/UseCases.kt | 27 ++++ .../optable/sdk/core/network/NetworkClient.kt | 11 +- .../sdk/core/network/edge/EdgeService.kt | 9 +- .../java/co/optable/sdk/OptableSDKTest.kt | 122 +++++++++++++++++- .../co/optable/sdk/core/AppInfoHolderTest.kt | 84 ++++++++++++ .../sdk/core/IdentifiersEncoderTest.kt | 33 ++++- .../java/co/optable/sdk/core/UseCasesTest.kt | 36 ++++++ .../sdk/core/network/NetworkClientTest.kt | 28 +++- .../sdk/core/network/edge/EdgeServiceTest.kt | 99 ++++++++++++++ 13 files changed, 577 insertions(+), 19 deletions(-) create mode 100644 android_sdk/src/main/java/co/optable/sdk/core/AppInfoHolder.kt create mode 100644 android_sdk/src/test/java/co/optable/sdk/core/AppInfoHolderTest.kt create mode 100644 android_sdk/src/test/java/co/optable/sdk/core/network/edge/EdgeServiceTest.kt diff --git a/README.md b/README.md index c2cb654..e54aecd 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,64 @@ On success, the resulting key values are typically sent as part of a subsequent either call `targeting()` before each ad call, or in parallel periodically, caching the resulting key values which you then provide in ad calls. +#### Resolver hints (ID5 Mobile In-App) + +Some third-party identity resolvers, such as [ID5](https://id5.io/) Mobile In-App, require additional context to return +an identifier. The SDK automatically attaches the following parameters to every `targeting` call: + +- `bundle` — the app's package name. +- `ver` — the app's version name. +- `ua` — the device user agent (the same value sent in the `User-Agent` header; overridable via `customUserAgent`). + +You can supply additional hint identifiers (HIDs) through an overload of `targeting()` that accepts a `hids` list. Each +hint is encoded exactly like the primary identifiers — with its Optable type prefix (for example `e:`, `p:`, `a:`, `g:`, +`i6:`, and custom `c1:`…`c9:` used for IDFV or a publisher-provided user ID), and, as with the primary `ids` list, the +device's Google Advertising ID is added automatically when available. + +The ID5 signature is managed by the SDK: when a `targeting` response returns one, it is cached on the device and resent +automatically on subsequent `targeting` calls to increase ID5 resolve rates and accuracy. No application code is required. + +##### Kotlin + +```kotlin +val ids = listOf(OptableIdentifier.Email("test@test.com")) +val hids = listOf( + OptableIdentifier.IPv6("2001:0db8:5b96:0000:0000:426f:8e17:642a"), + OptableIdentifier.PhoneNumber("+1(555)1234567"), + OptableIdentifier.AppleIdfa("a1b2c3d4-e5f6-7890-abcd-ef0123456789"), + OptableIdentifier.Custom("c1", "idfv-or-puid-value"), +) +optable.targeting(ids, hids) { result -> + when (result) { + is OptableResult.Success -> { + // Targeting key-values are available via result.data. + } + + is OptableResult.Error -> { + Log.d(TAG, "Targeting error: ${result.message}") + } + } +} +``` + +##### Java + +```java +ArrayList hids = Lists.newArrayList( + new OptableIdentifier.IPv6("2001:0db8:5b96:0000:0000:426f:8e17:642a"), + new OptableIdentifier.PhoneNumber("+1(555)1234567"), + new OptableIdentifier.AppleIdfa("a1b2c3d4-e5f6-7890-abcd-ef0123456789"), + new OptableIdentifier.Custom("c1", "idfv-or-puid-value") +); +optable.targeting(ids, hids, result -> { + if (result instanceof OptableResult.Success success) { + // success.getData() exposes the targeting key-values. + } else if (result instanceof OptableResult.Error error) { + Log.d(TAG, "Targeting error: " + error.getMessage()); + } +}); +``` + #### Caching Targeting Data The `targeting` API will automatically cache resulting key value data in client storage on success. You can subsequently diff --git a/android_sdk/src/main/java/co/optable/sdk/OptableSDK.kt b/android_sdk/src/main/java/co/optable/sdk/OptableSDK.kt index 874e52c..32bc001 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableSDK.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableSDK.kt @@ -30,6 +30,8 @@ class OptableSDK( private val storage = LocalStorage(config) private val consentsManager = ConsentsManager(storage) private val googleAdIdManager = GoogleAdIdManager(config) + private val userAgentHolder = UserAgentHolder(config) + private val appInfoHolder = AppInfoHolder(config) private val networkClient = createNetworkClient() private val useCases = UseCases() private val identifiersEncoder = IdentifiersEncoder(googleAdIdManager) @@ -135,15 +137,42 @@ class OptableSDK( * whitespace-stripped, or encrypted). Additional identifiers may be supplied through the `Raw` or `Custom` type. */ fun targeting(ids: List, listener: OptableResultListener) { + targeting(ids, emptyList(), listener) + } + + /** + * Calls the Optable Sandbox "targeting" API, which returns the key-value targeting + * data matching the user/device/app and part of the OpenRTB code snippet, + * additionally forwarding [hids] (hint identifiers) for third-party identity resolvers. + * @see OptableTargeting + * + * This type accepts multiple optional identifier values. When EIDs are generated, each supported + * identifier is encoded according to its identifier type (for example, some values are normalized, + * whitespace-stripped, or encrypted). Additional identifiers may be supplied through the `Raw` or `Custom` type. + */ + fun targeting( + ids: List, + hids: List, + listener: OptableResultListener, + ) { scope.launch { googleAdIdManager.deferredTask.await() - val ids = identifiersEncoder.encode(ids) - val response = networkClient.targeting(ids) + val encodedIds = identifiersEncoder.encode(ids) + val encodedHids = identifiersEncoder.encode(hids) + val response = networkClient.targeting( + ids = encodedIds, + hids = encodedHids, + bundle = appInfoHolder.bundle , + version = appInfoHolder.appVersion, + userAgent = userAgentHolder.getUserAgent(), + id5Signature = storage.getId5Signature(), + ) val optableResult = when (response) { is NetworkResponse.Success -> { val targeting = useCases.parseTargetingResponse(response.result) storage.setTargeting(targeting) + useCases.parseId5Signature(response.result)?.let { storage.setId5Signature(it) } OptableResult.Success(targeting) } @@ -208,7 +237,6 @@ class OptableSDK( } private fun createNetworkClient(): NetworkClient { - val userAgentHolder = UserAgentHolder(config) val requestInterceptor = RequestInterceptor(config, storage, userAgentHolder, consentsManager) val responseInterceptor = ResponseInterceptor(storage) return NetworkClient(config, requestInterceptor, responseInterceptor) diff --git a/android_sdk/src/main/java/co/optable/sdk/core/AppInfoHolder.kt b/android_sdk/src/main/java/co/optable/sdk/core/AppInfoHolder.kt new file mode 100644 index 0000000..75becc1 --- /dev/null +++ b/android_sdk/src/main/java/co/optable/sdk/core/AppInfoHolder.kt @@ -0,0 +1,44 @@ +package co.optable.sdk.core + +import android.content.Context +import android.util.Log +import co.optable.sdk.OptableConfig + +/** + * Resolves and caches platform-specific application information used by resolvers such as + * ID5 Mobile In-App and Audigent Hadron: + * - [bundle]: the application's package name (the mobile app's unique identifier). + * - [appVersion]: the application's version name. + * + * Both values are resolved once, at construction, and fall back to `null` (never an empty + * string) when they cannot be determined. + */ +internal class AppInfoHolder( + config: OptableConfig, +) { + + val bundle: String? = resolveBundle(config.context) + val appVersion: String? = resolveAppVersion(config.context) + + private fun resolveBundle(context: Context): String? { + return try { + context.packageName?.takeIf { it.isNotBlank() } + } catch (e: Exception) { + Log.w("OptableSDK", "Can't resolve app bundle: ${e.message}") + null + } + } + + private fun resolveAppVersion(context: Context): String? { + return try { + context.packageManager + .getPackageInfo(context.packageName, 0) + .versionName + ?.takeIf { it.isNotBlank() } + } catch (e: Exception) { + Log.w("OptableSDK", "Can't resolve app version: ${e.message}") + null + } + } + +} diff --git a/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt b/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt index c6e6cf0..1cf2d2c 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt @@ -28,6 +28,7 @@ internal class LocalStorage( private val prefs = PreferenceManager.getDefaultSharedPreferences(config.context) private val passportKey = generateUniqueKey("PASS", config) private val targetingKey = generateUniqueKey("TGT", config) + private val id5SignatureKey = generateUniqueKey("ID5SIG", config) fun getPassport(): String? { return prefs.getString(passportKey, null) @@ -66,6 +67,16 @@ internal class LocalStorage( } } + fun getId5Signature(): String? { + return prefs.getString(id5SignatureKey, null) + } + + fun setId5Signature(signature: String) { + prefs.edit(commit = true) { + putString(id5SignatureKey, signature) + } + } + fun getSubjectToGdpr(): Int? { try { val result = prefs.getInt(KEY_SUBJECT_TO_GDPR, -1) diff --git a/android_sdk/src/main/java/co/optable/sdk/core/UseCases.kt b/android_sdk/src/main/java/co/optable/sdk/core/UseCases.kt index f778eff..caa8d49 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/UseCases.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/UseCases.kt @@ -14,6 +14,33 @@ class UseCases { return OptableTargeting(gamTargetingKeywords, openRtbJson, targetingData) } + fun parseId5Signature(responseJson: JsonObject): String? { + try { + val eids = responseJson + .getAsJsonObject("ortb2") + ?.getAsJsonObject("user") + ?.getAsJsonArray("eids") ?: return null + + for (eid in eids) { + val eidObj = eid.asJsonObject + val source = eidObj.get("source")?.asString + if (source == null || !source.contains("id5", ignoreCase = true)) continue + + val uids = eidObj.getAsJsonArray("uids") ?: continue + for (uid in uids) { + val signature = uid.asJsonObject + .getAsJsonObject("ext") + ?.get("signature") + ?.asString + if (!signature.isNullOrBlank()) return signature + } + } + } catch (e: Exception) { + Log.d("OptableSDK", "Can't parse ID5 signature: ${e.message}") + } + return null + } + private fun parseTargetingData(responseJson: JsonObject): JSONObject { var targetingData = JSONObject() try { diff --git a/android_sdk/src/main/java/co/optable/sdk/core/network/NetworkClient.kt b/android_sdk/src/main/java/co/optable/sdk/core/network/NetworkClient.kt index d0992e4..5e961d7 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/network/NetworkClient.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/network/NetworkClient.kt @@ -53,9 +53,16 @@ internal class NetworkClient( } } - suspend fun targeting(ids: List): NetworkResponse { + suspend fun targeting( + ids: List, + hids: List, + bundle: String?, + version: String?, + userAgent: String?, + id5Signature: String?, + ): NetworkResponse { return runSafe { - edgeService.targeting(ids) + edgeService.targeting(ids, hids, bundle, version, userAgent, id5Signature) } } diff --git a/android_sdk/src/main/java/co/optable/sdk/core/network/edge/EdgeService.kt b/android_sdk/src/main/java/co/optable/sdk/core/network/edge/EdgeService.kt index 9beaba3..4f693e4 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/network/edge/EdgeService.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/network/edge/EdgeService.kt @@ -15,7 +15,14 @@ import retrofit2.http.Query interface EdgeService { @GET("targeting") - suspend fun targeting(@Query("id") idList: List): Response + suspend fun targeting( + @Query("id") idList: List, + @Query("hid") hidList: List, + @Query("bundle") bundle: String?, + @Query("ver") version: String?, + @Query("ua") userAgent: String?, + @Query("id5_signature") id5Signature: String?, + ): Response @POST("identify") suspend fun identify(@Body ids: List): Response diff --git a/android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt b/android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt index 6cf0dc2..27cb4b9 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt @@ -2,9 +2,11 @@ package co.optable.sdk import android.util.Base64 import android.util.Log +import co.optable.sdk.core.AppInfoHolder import co.optable.sdk.core.ConsentsManager import co.optable.sdk.core.LocalStorage import co.optable.sdk.core.UseCases +import co.optable.sdk.core.UserAgentHolder import co.optable.sdk.core.network.NetworkClient import co.optable.sdk.core.network.NetworkResponse import com.google.gson.JsonObject @@ -39,7 +41,9 @@ class OptableSDKTest { every { mockConfig.getBaseUrl() } returns "https://test.com" mockNetworkClient = mockk() mockStorage = mockk(relaxed = true) + every { mockStorage.getId5Signature() } returns null mockUseCases = mockk() + every { mockUseCases.parseId5Signature(any()) } returns null mockConsentsManager = mockk(relaxed = true) mockkStatic(Log::class) @@ -182,7 +186,9 @@ class OptableSDKTest { val listener = mockk>(relaxed = true) val targetingJson = JsonObject() val expectedTargeting = mockk() - coEvery { mockNetworkClient.targeting(emptyList()) } returns NetworkResponse.Success(targetingJson) + coEvery { + mockNetworkClient.targeting(emptyList(), emptyList(), any(), any(), any(), any()) + } returns NetworkResponse.Success(targetingJson) every { mockUseCases.parseTargetingResponse(targetingJson) } returns expectedTargeting sdk.targeting(emptyList(), listener) @@ -199,7 +205,9 @@ class OptableSDKTest { fun `targeting error should call listener with error`() = runTest(testDispatcher) { val listener = mockk>(relaxed = true) val errorMessage = "Targeting Failure" - coEvery { mockNetworkClient.targeting(emptyList()) } returns NetworkResponse.Error(errorMessage) + coEvery { + mockNetworkClient.targeting(emptyList(), emptyList(), any(), any(), any(), any()) + } returns NetworkResponse.Error(errorMessage) sdk.targeting(emptyList(), listener) advanceUntilIdle() @@ -210,6 +218,116 @@ class OptableSDKTest { assertTrue(slot.captured is OptableResult.Error) } + @Test + fun `targeting forwards encoded hints and cached id5 signature`() = runTest(testDispatcher) { + val listener = mockk>(relaxed = true) + val targetingJson = JsonObject() + val expectedTargeting = mockk() + every { mockStorage.getId5Signature() } returns "sig-xyz" + coEvery { + mockNetworkClient.targeting(any(), any(), any(), any(), any(), any()) + } returns NetworkResponse.Success(targetingJson) + every { mockUseCases.parseTargetingResponse(targetingJson) } returns expectedTargeting + + val hids = listOf(OptableIdentifier.IPv6("2001:DB8::1")) + sdk.targeting(emptyList(), hids, listener) + advanceUntilIdle() + + coVerify { + mockNetworkClient.targeting( + emptyList(), + listOf("i6:2001:db8::1"), + any(), + any(), + any(), + "sig-xyz", + ) + } + } + + @Test + fun `targeting stores id5 signature extracted from response`() = runTest(testDispatcher) { + val listener = mockk>(relaxed = true) + val targetingJson = JsonObject() + coEvery { + mockNetworkClient.targeting(any(), any(), any(), any(), any(), any()) + } returns NetworkResponse.Success(targetingJson) + every { mockUseCases.parseTargetingResponse(targetingJson) } returns mockk() + every { mockUseCases.parseId5Signature(targetingJson) } returns "new-sig" + + sdk.targeting(emptyList(), listener) + advanceUntilIdle() + + verify { mockStorage.setId5Signature("new-sig") } + } + + @Test + fun `targeting keeps previous id5 signature when response has none`() = runTest(testDispatcher) { + val listener = mockk>(relaxed = true) + val targetingJson = JsonObject() + coEvery { + mockNetworkClient.targeting(any(), any(), any(), any(), any(), any()) + } returns NetworkResponse.Success(targetingJson) + every { mockUseCases.parseTargetingResponse(targetingJson) } returns mockk() + every { mockUseCases.parseId5Signature(targetingJson) } returns null + + sdk.targeting(emptyList(), listener) + advanceUntilIdle() + + verify(exactly = 0) { mockStorage.setId5Signature(any()) } + } + + @Test + fun `targeting forwards app info when sendAppInfo is true`() = runTest(testDispatcher) { + injectAppInfo(bundle = "co.optable.app", appVersion = "1.2.3", userAgent = "ua-string") + + val listener = mockk>(relaxed = true) + val targetingJson = JsonObject() + coEvery { + mockNetworkClient.targeting(any(), any(), any(), any(), any(), any()) + } returns NetworkResponse.Success(targetingJson) + every { mockUseCases.parseTargetingResponse(targetingJson) } returns mockk() + + sdk.targeting(emptyList(), listener) + advanceUntilIdle() + + // Pins both the gate (true -> forwarded) and the holder field -> param position mapping. + coVerify { + mockNetworkClient.targeting(emptyList(), emptyList(), "co.optable.app", "1.2.3", "ua-string", null) + } + } + + @Test + fun `targeting suppresses app info when sendAppInfo is false`() = runTest(testDispatcher) { + injectAppInfo(bundle = "co.optable.app", appVersion = "1.2.3", userAgent = "ua-string") + + val listener = mockk>(relaxed = true) + val targetingJson = JsonObject() + coEvery { + mockNetworkClient.targeting(any(), any(), any(), any(), any(), any()) + } returns NetworkResponse.Success(targetingJson) + every { mockUseCases.parseTargetingResponse(targetingJson) } returns mockk() + + sdk.targeting(emptyList(), listener) + advanceUntilIdle() + + coVerify { + mockNetworkClient.targeting(emptyList(), emptyList(), null, null, null, null) + } + } + + private fun injectAppInfo(bundle: String?, appVersion: String?, userAgent: String?) { + val mockAppInfoHolder = mockk { + every { this@mockk.bundle } returns bundle + every { this@mockk.appVersion } returns appVersion + } + val mockUserAgentHolder = mockk { + every { getUserAgent() } returns userAgent + } + setPrivateField(sdk, "appInfoHolder", mockAppInfoHolder) + setPrivateField(sdk, "userAgentHolder", mockUserAgentHolder) + } + @Test fun `targetingFromCache should return data from storage`() { val expectedTargeting = OptableTargeting(emptyMap(), null, JSONObject()) diff --git a/android_sdk/src/test/java/co/optable/sdk/core/AppInfoHolderTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/AppInfoHolderTest.kt new file mode 100644 index 0000000..1d7417f --- /dev/null +++ b/android_sdk/src/test/java/co/optable/sdk/core/AppInfoHolderTest.kt @@ -0,0 +1,84 @@ +package co.optable.sdk.core + +import android.content.Context +import co.optable.sdk.OptableConfig +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AppInfoHolderTest { + + @Test + fun `bundle resolves to context package name`() { + val context = RuntimeEnvironment.getApplication() + val config = mockk() + every { config.context } returns context + + val holder = AppInfoHolder(config) + + assertEquals(context.packageName, holder.bundle) + } + + @Test + fun `appVersion resolves from package manager`() { + val context = RuntimeEnvironment.getApplication() + shadowOf(context.packageManager) + .getInternalMutablePackageInfo(context.packageName) + .versionName = "9.8.7" + val config = mockk() + every { config.context } returns context + + val holder = AppInfoHolder(config) + + assertEquals("9.8.7", holder.appVersion) + } + + @Test + fun `appVersion is null when version name is blank`() { + val context = RuntimeEnvironment.getApplication() + shadowOf(context.packageManager) + .getInternalMutablePackageInfo(context.packageName) + .versionName = " " + val config = mockk() + every { config.context } returns context + + val holder = AppInfoHolder(config) + + assertNull(holder.appVersion) + } + + @Test + fun `appVersion is null and bundle still resolves when package manager throws`() { + val context = mockk() + every { context.packageName } returns "co.optable.app" + every { context.packageManager } throws RuntimeException("boom") + val config = mockk() + every { config.context } returns context + + val holder = AppInfoHolder(config) + + assertNull(holder.appVersion) + assertEquals("co.optable.app", holder.bundle) + } + + @Test + fun `bundle is null when package name is blank`() { + val context = mockk(relaxed = true) + every { context.packageName } returns " " + val config = mockk() + every { config.context } returns context + + val holder = AppInfoHolder(config) + + assertNull(holder.bundle) + } +} diff --git a/android_sdk/src/test/java/co/optable/sdk/core/IdentifiersEncoderTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/IdentifiersEncoderTest.kt index 12af498..871b71c 100644 --- a/android_sdk/src/test/java/co/optable/sdk/core/IdentifiersEncoderTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/core/IdentifiersEncoderTest.kt @@ -6,6 +6,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -34,12 +35,34 @@ class IdentifiersEncoderTest { @Test fun `phoneNumber is encrypted`() { - val expected = "e:97ed57b6d666c803e1d0a74b0e3ecc5157f23c02800d007f2e4b8b2fabea8dbb" + val expected = "p:97ed57b6d666c803e1d0a74b0e3ecc5157f23c02800d007f2e4b8b2fabea8dbb" - assertEquals(expected, encode(OptableIdentifier.Email("+123467890"))) - assertEquals(expected, encode(OptableIdentifier.Email(" +123467890"))) - assertEquals(expected, encode(OptableIdentifier.Email("+123467890 "))) - assertEquals(expected, encode(OptableIdentifier.Email(" +123467890 "))) + assertEquals(expected, encode(OptableIdentifier.PhoneNumber("+123467890"))) + assertEquals(expected, encode(OptableIdentifier.PhoneNumber(" +123467890"))) + assertEquals(expected, encode(OptableIdentifier.PhoneNumber("+123467890 "))) + assertEquals(expected, encode(OptableIdentifier.PhoneNumber(" +123467890 "))) + } + + @Test + fun `primary ids include device gaid`() { + whenever(mockGoogleAdIdManager.getId()).thenReturn("38400000-8cf0-11bd-b23e-10b96e40000d") + + val ids = identifiersEncoder.encode(listOf(OptableIdentifier.IPv6("2001:db8::1"))) + + assertEquals( + listOf("g:38400000-8cf0-11bd-b23e-10b96e40000d", "i6:2001:db8::1"), + ids, + ) + } + + @Test + fun `caller gaid matching device gaid is deduped for primary ids`() { + val deviceGaid = "38400000-8cf0-11bd-b23e-10b96e40000d" + whenever(mockGoogleAdIdManager.getId()).thenReturn(deviceGaid) + + val ids = identifiersEncoder.encode(listOf(OptableIdentifier.GoogleGaid(deviceGaid))) + + assertEquals(listOf("g:$deviceGaid"), ids) } @Test diff --git a/android_sdk/src/test/java/co/optable/sdk/core/UseCasesTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/UseCasesTest.kt index 6813350..e6788e1 100644 --- a/android_sdk/src/test/java/co/optable/sdk/core/UseCasesTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/core/UseCasesTest.kt @@ -52,6 +52,18 @@ class UseCasesTest { assertEquals(emptyMap>(), result.gamTargetingKeywords) } + @Test + fun `parse id5 signature from response`() { + val responseJson = Gson().fromJson(RESPONSE_WITH_ID5, JsonObject::class.java) + assertEquals("id5-signature-value", useCases.parseId5Signature(responseJson)) + } + + @Test + fun `parse id5 signature returns null when absent`() { + val responseJson = Gson().fromJson(RESPONSE, JsonObject::class.java) + assertEquals(null, useCases.parseId5Signature(responseJson)) + } + @Test fun `parse targeting blank`() { val responseJson = Gson().fromJson("{}", JsonObject::class.java) @@ -71,6 +83,30 @@ class UseCasesTest { private const val EXPECTED_TARGETING_DATA = """{"user":[],"audience":[{"provider":"optable.co","ids":[{"id":"test1"},{"id":"test2"}],"keyspace":"optable-test","rtb_segtax":5001}],"ortb2":{"user":{"data":[{"id":"optable.co","segment":[{"id":"test1"},{"id":"test2"}]}],"eids":[{"inserter":"optable.co","matcher":"optable.co","mm":3,"source":"optable.co","uids":[{"id":"e:f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a"},{"id":"v:17pZOV6BmGyUYifUNpYJsq"},{"id":"v:2UrRt9jeXPXAxxxsxTbGu9"},{"id":"v:4B4qgjdrBYtU9tn0RfjqJY"},{"id":"v:5WX5fAM6pyoEQXUDaJy0Um"},{"id":"v:5dJU7HMbmcHzut5uk0hJk4"},{"id":"v:6wZ8XnZ2FuO8EQSxGK3OnS"},{"id":"v:74gpCwdJknMdvLgQvfuKvu"}]},{"inserter":"optable.co","matcher":"optable.co","mm":3,"source":"criteo-hemapi.com","uids":[{"id":"QcOx619EVCUyQkl6d0xDZkJCWmIlMkYzUHBDZU0yN2sxbkpIMERtWEgydVJSNXUxbmVHYUZJNWJZYkpqN2JmeUZnRjlPMmJNWVRzUVhnNDhBRGFjS2dnM016ZWpPaVU4dG5uRXhmdTlFbmNPUm8xeWRsdFklM0Q","atype":3,"ext":{"stype":"cto_bundle_hem_api"}}]}]}},"resolved_ids":["e:f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a"]}""" + private const val RESPONSE_WITH_ID5 = """ +{ + "ortb2": { + "user": { + "eids": [ + { + "source": "id5-sync.com", + "uids": [ + { + "id": "ID5-abc", + "atype": 1, + "ext": { + "linkType": 2, + "signature": "id5-signature-value" + } + } + ] + } + ] + } + } +} + """ + private const val RESPONSE_WITHOUT_AUDIENCES = """ { "user": [], diff --git a/android_sdk/src/test/java/co/optable/sdk/core/network/NetworkClientTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/network/NetworkClientTest.kt index c32c1b1..bd8246e 100644 --- a/android_sdk/src/test/java/co/optable/sdk/core/network/NetworkClientTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/core/network/NetworkClientTest.kt @@ -129,9 +129,9 @@ class NetworkClientTest { val jsonObject = JsonObject().apply { addProperty("targetingKey", "targetingValue") } val expectedResponse: Response = Response.success(jsonObject) - `when`(mockEdgeService.targeting(emptyList())).thenReturn(expectedResponse) + `when`(mockEdgeService.targeting(emptyList(), emptyList(), null, null, null, null)).thenReturn(expectedResponse) - val result = networkClient.targeting(emptyList()) + val result = networkClient.targeting(emptyList(), emptyList(), null, null, null, null) assertTrue(result is NetworkResponse.Success) assertEquals(jsonObject, (result as NetworkResponse.Success).result) @@ -142,9 +142,9 @@ class NetworkClientTest { val errorBody = ResponseBody.create(null, "Error") val expectedResponse: Response = Response.error(500, errorBody) - `when`(mockEdgeService.targeting(emptyList())).thenReturn(expectedResponse) + `when`(mockEdgeService.targeting(emptyList(), emptyList(), null, null, null, null)).thenReturn(expectedResponse) - val result = networkClient.targeting(emptyList()) + val result = networkClient.targeting(emptyList(), emptyList(), null, null, null, null) assertTrue(result is NetworkResponse.Error) } @@ -153,14 +153,30 @@ class NetworkClientTest { fun targeting_shouldReturnError_whenApiCallThrowsException() = runBlocking { val exception = RuntimeException("Network error") - `when`(mockEdgeService.targeting(emptyList())).thenThrow(exception) + `when`(mockEdgeService.targeting(emptyList(), emptyList(), null, null, null, null)).thenThrow(exception) - val result = networkClient.targeting(emptyList()) + val result = networkClient.targeting(emptyList(), emptyList(), null, null, null, null) assertTrue(result is NetworkResponse.Error) assertEquals(exception.message, (result as NetworkResponse.Error).message) } + @Test + fun targeting_shouldForwardAllParamsToEdgeService() = runBlocking { + val jsonObject = JsonObject() + val expectedResponse: Response = Response.success(jsonObject) + val ids = listOf("e:abc") + val hids = listOf("i6:2001:db8::1") + + `when`(mockEdgeService.targeting(ids, hids, "co.optable.app", "1.2.3", "ua-string", "sig")) + .thenReturn(expectedResponse) + + val result = networkClient.targeting(ids, hids, "co.optable.app", "1.2.3", "ua-string", "sig") + + assertTrue(result is NetworkResponse.Success) + assertEquals(jsonObject, (result as NetworkResponse.Success).result) + } + @Test fun witness_shouldReturnSuccess_whenApiCallIsSuccessful() = runBlocking { val event = "test_event" diff --git a/android_sdk/src/test/java/co/optable/sdk/core/network/edge/EdgeServiceTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/network/edge/EdgeServiceTest.kt new file mode 100644 index 0000000..6a0bb93 --- /dev/null +++ b/android_sdk/src/test/java/co/optable/sdk/core/network/edge/EdgeServiceTest.kt @@ -0,0 +1,99 @@ +package co.optable.sdk.core.network.edge + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +class EdgeServiceTest { + + private lateinit var server: MockWebServer + private lateinit var service: EdgeService + + @Before + fun setUp() { + server = MockWebServer() + server.start() + service = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(EdgeService::class.java) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `targeting sends all params, hids repeated and URL-component encoded`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + service.targeting( + idList = listOf("e:abc", "g:123"), + hidList = listOf("e:16bbea8f", "i6:2001:0db8::1"), + bundle = "co.optable.app", + version = "1.2.3", + userAgent = "Mozilla/5.0 (Linux; Android)", + id5Signature = "sig+123", + ) + + val recorded = server.takeRequest() + val url = recorded.requestUrl!! + + // Decoded values + assertEquals(listOf("e:abc", "g:123"), url.queryParameterValues("id")) + assertEquals(listOf("e:16bbea8f", "i6:2001:0db8::1"), url.queryParameterValues("hid")) + assertEquals("co.optable.app", url.queryParameter("bundle")) + assertEquals("1.2.3", url.queryParameter("ver")) + assertEquals("Mozilla/5.0 (Linux; Android)", url.queryParameter("ua")) + assertEquals("sig+123", url.queryParameter("id5_signature")) + + // Raw wire encoding: every param must be URL-component encoded. + val rawPath = recorded.path!! + // Identifier prefixes' colons -> %3A + assertTrue(rawPath, rawPath.contains("id=e%3Aabc")) + assertTrue(rawPath, rawPath.contains("hid=e%3A16bbea8f")) + assertTrue(rawPath, rawPath.contains("hid=i6%3A2001%3A0db8%3A%3A1")) + // Scalar params: reserved '+' -> %2B (not left literal / form-encoded as space) + assertTrue(rawPath, rawPath.contains("id5_signature=sig%2B123")) + assertTrue(rawPath, rawPath.contains("bundle=co.optable.app")) + assertTrue(rawPath, rawPath.contains("ver=1.2.3")) + // User agent: space -> %20 (not '+'), '/' -> %2F, '(' ';' ')' -> %28 %3B %29 + assertTrue(rawPath, rawPath.contains("ua=Mozilla%2F5.0%20%28Linux%3B%20Android%29")) + } + + @Test + fun `targeting omits optional params when null or empty`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + service.targeting( + idList = listOf("e:abc"), + hidList = emptyList(), + bundle = null, + version = null, + userAgent = null, + id5Signature = null, + ) + + val recorded = server.takeRequest() + val url = recorded.requestUrl!! + + assertEquals(listOf("e:abc"), url.queryParameterValues("id")) + assertTrue(url.queryParameterValues("hid").isEmpty()) + assertNull(url.queryParameter("bundle")) + assertNull(url.queryParameter("ver")) + assertNull(url.queryParameter("ua")) + assertNull(url.queryParameter("id5_signature")) + assertFalse(recorded.path!!.contains("hid=")) + } +} From af42be688e646adf18378c884e4a3c6bcb02fae5 Mon Sep 17 00:00:00 2001 From: Riabkov Date: Wed, 15 Jul 2026 14:16:26 +0300 Subject: [PATCH 2/2] - Update id5 parsing, take from refs - Update unit tests --- .../java/co/optable/sdk/core/LocalStorage.kt | 1 + .../main/java/co/optable/sdk/core/UseCases.kt | 10 ++++++++- .../java/co/optable/sdk/OptableSDKTest.kt | 21 +------------------ .../java/co/optable/sdk/core/UseCasesTest.kt | 18 +++++++++++++--- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt b/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt index 1cf2d2c..565f7f6 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt @@ -64,6 +64,7 @@ internal class LocalStorage( fun clearTargeting() { prefs.edit(commit = true) { remove(targetingKey) + remove(id5SignatureKey) } } diff --git a/android_sdk/src/main/java/co/optable/sdk/core/UseCases.kt b/android_sdk/src/main/java/co/optable/sdk/core/UseCases.kt index caa8d49..853544e 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/UseCases.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/UseCases.kt @@ -21,6 +21,8 @@ class UseCases { ?.getAsJsonObject("user") ?.getAsJsonArray("eids") ?: return null + val refs = responseJson.getAsJsonObject("refs") + for (eid in eids) { val eidObj = eid.asJsonObject val source = eidObj.get("source")?.asString @@ -28,8 +30,14 @@ class UseCases { val uids = eidObj.getAsJsonArray("uids") ?: continue for (uid in uids) { - val signature = uid.asJsonObject + val ref = uid.asJsonObject .getAsJsonObject("ext") + ?.getAsJsonObject("optable") + ?.get("ref") + ?.asString ?: continue + + val signature = refs + ?.getAsJsonObject(ref) ?.get("signature") ?.asString if (!signature.isNullOrBlank()) return signature diff --git a/android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt b/android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt index 27cb4b9..815b1fe 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt @@ -278,7 +278,7 @@ class OptableSDKTest { } @Test - fun `targeting forwards app info when sendAppInfo is true`() = runTest(testDispatcher) { + fun `targeting forwards app info`() = runTest(testDispatcher) { injectAppInfo(bundle = "co.optable.app", appVersion = "1.2.3", userAgent = "ua-string") val listener = mockk>(relaxed = true) @@ -297,25 +297,6 @@ class OptableSDKTest { } } - @Test - fun `targeting suppresses app info when sendAppInfo is false`() = runTest(testDispatcher) { - injectAppInfo(bundle = "co.optable.app", appVersion = "1.2.3", userAgent = "ua-string") - - val listener = mockk>(relaxed = true) - val targetingJson = JsonObject() - coEvery { - mockNetworkClient.targeting(any(), any(), any(), any(), any(), any()) - } returns NetworkResponse.Success(targetingJson) - every { mockUseCases.parseTargetingResponse(targetingJson) } returns mockk() - - sdk.targeting(emptyList(), listener) - advanceUntilIdle() - - coVerify { - mockNetworkClient.targeting(emptyList(), emptyList(), null, null, null, null) - } - } - private fun injectAppInfo(bundle: String?, appVersion: String?, userAgent: String?) { val mockAppInfoHolder = mockk { every { this@mockk.bundle } returns bundle diff --git a/android_sdk/src/test/java/co/optable/sdk/core/UseCasesTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/UseCasesTest.kt index e6788e1..e7b75e4 100644 --- a/android_sdk/src/test/java/co/optable/sdk/core/UseCasesTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/core/UseCasesTest.kt @@ -93,16 +93,28 @@ class UseCasesTest { "uids": [ { "id": "ID5-abc", - "atype": 1, + "atype": 2, "ext": { - "linkType": 2, - "signature": "id5-signature-value" + "linktype": 2, + "optable": { + "ref": "0" + } } } ] } ] } + }, + "refs": { + "0": { + "created_at": "2026-07-14T22:20:25Z", + "signature": "id5-signature-value", + "privacy": { + "jurisdiction": "other", + "id5_consent": true + } + } } } """