From 58ac370f0e056d443cb2a08e2ad41d6f9c9fa801 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 9 Nov 2026 18:38:51 -0700 Subject: [PATCH 1/3] Read the Google Advertising ID on every call, waiting up to 100 ms The SDK read the GAID once when an instance was created and kept it in a companion object shared by every instance, so a reset or opt-out was ignored until the process restarted, and an instance created with skipAdvertisingIdDetection still sent a GAID an earlier instance read. identify and targeting also waited for that read behind a 3 second timeout that couldn't interrupt the blocking getAdvertisingIdInfo(). Google says to call getAdvertisingIdInfo() each time the ID is needed and not to cache it. identify and targeting now read the GAID and the opt-out flag on every call, like the iOS SDK reads the IDFA, waiting at most OptableConfig.advertisingIdTimeoutMs (default 100 ms) before sending the call without it. Zero or less means don't wait. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 + .../main/java/co/optable/sdk/OptableConfig.kt | 6 + .../main/java/co/optable/sdk/OptableSDK.kt | 18 +- .../co/optable/sdk/core/GoogleAdIdManager.kt | 49 +++--- .../co/optable/sdk/core/IdentifiersEncoder.kt | 10 +- .../java/co/optable/sdk/OptableConfigTest.kt | 5 +- .../co/optable/sdk/OptableIdentifiersTest.kt | 27 +-- .../optable/sdk/core/GoogleAdIdManagerTest.kt | 158 +++++------------- .../sdk/core/IdentifiersEncoderTest.kt | 17 +- 9 files changed, 102 insertions(+), 192 deletions(-) diff --git a/README.md b/README.md index 3ea430a..de9438e 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,9 @@ developers running the DCN locally for testing. - `origin` An optional value for the HTTP `Origin` header sent with Optable API requests. Unrelated to `originSlug`. - `cacheTtl` The time to live, in seconds, of the targeting data cached by the `targeting` API. Default value is 24 hours. See [Caching Targeting Data](#caching-targeting-data). +- `advertisingIdTimeoutMs` How long, in milliseconds, an `identify` or `targeting` call waits for the Google Advertising +ID before it is sent without it. Default value is 100. Zero or less means don't wait, so the Google Advertising ID is +never added automatically. ```kotlin val config = OptableConfig( @@ -157,6 +160,7 @@ val config = OptableConfig( consents = OptableConsents(), origin = "https://www.acmeco.com", cacheTtl = 24 * 60 * 60L, + advertisingIdTimeoutMs = 100L, ) ``` diff --git a/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt b/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt index 391bdf7..8baaa00 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt @@ -19,6 +19,10 @@ import android.content.Context * Settable after construction, which is how Java callers set it without passing every preceding parameter. * @param cacheTtl The targeting cache time to live in seconds. Default value is 24 hours. * A value of zero or less disables the cache. + * @param advertisingIdTimeoutMs How long, in milliseconds, an `identify` or `targeting` call waits for the Google + * Advertising ID before it is sent without it. Default value is 100. Zero or less means don't wait, so the + * Advertising ID is never added automatically; there is no setting that waits forever. Settable after construction, + * like `origin`. */ class OptableConfig @JvmOverloads constructor( providedContext: Context, @@ -33,10 +37,12 @@ class OptableConfig @JvmOverloads constructor( var consents: OptableConsents = OptableConsents(), var origin: String? = null, internal val cacheTtl: Long = DEFAULT_TARGETING_CACHE_TTL_SECONDS, + var advertisingIdTimeoutMs: Long = DEFAULT_ADVERTISING_ID_TIMEOUT_MS, ) { companion object { const val DEFAULT_TARGETING_CACHE_TTL_SECONDS: Long = 24 * 60 * 60L + const val DEFAULT_ADVERTISING_ID_TIMEOUT_MS: Long = 100L } internal val context = providedContext.applicationContext 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 2c1d270..3bb3c68 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableSDK.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableSDK.kt @@ -35,25 +35,19 @@ class OptableSDK( private val appInfoHolder = AppInfoHolder(config) private val networkClient = createNetworkClient() private val useCases = UseCases() - private val identifiersEncoder = IdentifiersEncoder(googleAdIdManager) + private val identifiersEncoder = IdentifiersEncoder() private val ceh = CoroutineExceptionHandler { _, e -> Log.e("OptableSDK", "Internal exception: $e") } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + ceh) - init { - scope.launch { - googleAdIdManager.fetchAdvertisingId() - } - } - /** * Calls the Optable DCN "identify" API, passing it the list of IDs, * a list of type-prefixed identifiers. */ fun identify(ids: List, listener: OptableResultListener) { scope.launch { - googleAdIdManager.deferredTask.await() - val encodedIds = identifiersEncoder.encode(ids) + val googleAdId = googleAdIdManager.getId() + val encodedIds = identifiersEncoder.encode(ids, googleAdId) val response = networkClient.identify(encodedIds) MainScope().launch { @@ -157,9 +151,9 @@ class OptableSDK( listener: OptableResultListener, ) { scope.launch { - googleAdIdManager.deferredTask.await() - val encodedIds = identifiersEncoder.encode(ids) - val encodedHids = identifiersEncoder.encode(hids) + val googleAdId = googleAdIdManager.getId() + val encodedIds = identifiersEncoder.encode(ids, googleAdId) + val encodedHids = identifiersEncoder.encode(hids, googleAdId) val response = networkClient.targeting( ids = encodedIds, hids = encodedHids, diff --git a/android_sdk/src/main/java/co/optable/sdk/core/GoogleAdIdManager.kt b/android_sdk/src/main/java/co/optable/sdk/core/GoogleAdIdManager.kt index 5ffb46a..8790587 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/GoogleAdIdManager.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/GoogleAdIdManager.kt @@ -3,48 +3,43 @@ package co.optable.sdk.core import android.util.Log import co.optable.sdk.OptableConfig import com.google.android.gms.ads.identifier.AdvertisingIdClient -import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.withTimeoutOrNull internal class GoogleAdIdManager( val config: OptableConfig, ) { - companion object { - private var adId: String? = null - private var limitAdTracking: Boolean? = null - } - - val deferredTask = CompletableDeferred() - - fun getId(): String? { - if (limitAdTracking == true) { + // getAdvertisingIdInfo() blocks and can't be cancelled, so reads run here and a caller stops waiting after + // config.advertisingIdTimeoutMs, leaving a slow read to finish on its own. + private val readScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** + * Reads the GAID for one API call. Google says not to cache it, since the user can reset it or opt out at any + * time, so it's read every time. Returns null when skipAdvertisingIdDetection is set, the user has opted out, or + * the read fails or takes longer than config.advertisingIdTimeoutMs. A timeout of zero or less means don't wait, + * so the GAID isn't read at all. + */ + suspend fun getId(): String? { + if (config.skipAdvertisingIdDetection || config.advertisingIdTimeoutMs <= 0) { return null } - return adId - } - - suspend fun fetchAdvertisingId() { - if (config.skipAdvertisingIdDetection) { - deferredTask.complete(null) - return - } - val id = withContext(Dispatchers.IO) { - withTimeoutOrNull(3_000) { - fetch() - } + val read = readScope.async { read() } + return withTimeoutOrNull(config.advertisingIdTimeoutMs) { + read.await() } - deferredTask.complete(id) } - private fun fetch(): String? { + private fun read(): String? { try { val adInfo = AdvertisingIdClient.getAdvertisingIdInfo(config.context) - adId = adInfo.id - limitAdTracking = adInfo.isLimitAdTrackingEnabled + if (adInfo.isLimitAdTrackingEnabled) { + return null + } return adInfo.id } catch (exception: Exception) { Log.w("OptableGaidManager", "Failed to fetch advertising ID: " + exception.message) diff --git a/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt b/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt index 1564edb..5dbf733 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt @@ -9,9 +9,7 @@ import java.util.* /** * Utility object responsible for encoding raw identifiers into Optable EIDs. */ -internal class IdentifiersEncoder( - private val googleAdIdManager: GoogleAdIdManager, -) { +internal class IdentifiersEncoder { companion object { private const val EMAIL = "e" @@ -31,10 +29,12 @@ internal class IdentifiersEncoder( } - fun encode(identifiers: List): List { + /** + * @param googleAdId The device's GAID, added first when not null. + */ + fun encode(identifiers: List, googleAdId: String? = null): List { val result = mutableListOf() - val googleAdId = googleAdIdManager.getId() if (googleAdId != null) { result.addIfNotNull(GAID, googleAdId, ::normalize) } diff --git a/android_sdk/src/test/java/co/optable/sdk/OptableConfigTest.kt b/android_sdk/src/test/java/co/optable/sdk/OptableConfigTest.kt index 4afc023..415afa6 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableConfigTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableConfigTest.kt @@ -43,6 +43,7 @@ class OptableConfigTest { assertEquals(mockApplicationContext, config.context) assertEquals(24 * 60 * 60L, config.cacheTtl) assertEquals(OptableConfig.DEFAULT_TARGETING_CACHE_TTL_SECONDS, config.cacheTtl) + assertEquals(100L, config.advertisingIdTimeoutMs) } @Test @@ -60,7 +61,8 @@ class OptableConfigTest { skipAdvertisingIdDetection = true, consents = customConsents, cacheTtl = 60L, - origin = "https://www.optable.co" + origin = "https://www.optable.co", + advertisingIdTimeoutMs = 250L, ) assertEquals("custom-tenant", config.tenant) @@ -74,6 +76,7 @@ class OptableConfigTest { assertEquals(customConsents, config.consents) assertEquals("https://www.optable.co", config.origin) assertEquals(60L, config.cacheTtl) + assertEquals(250L, config.advertisingIdTimeoutMs) } @Test diff --git a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt index 96cda77..5c2f3a5 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt @@ -1,9 +1,6 @@ package co.optable.sdk -import co.optable.sdk.core.GoogleAdIdManager import co.optable.sdk.core.IdentifiersEncoder -import io.mockk.every -import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -13,14 +10,10 @@ import java.util.* class OptableIdentifiersTest { private lateinit var identifiersEncoder: IdentifiersEncoder - private lateinit var mockGoogleAdIdManager: GoogleAdIdManager @Before fun setUp() { - mockGoogleAdIdManager = mockk() - every { mockGoogleAdIdManager.getId() } returns null - - identifiersEncoder = IdentifiersEncoder(mockGoogleAdIdManager) + identifiersEncoder = IdentifiersEncoder() } @Test @@ -224,8 +217,6 @@ class OptableIdentifiersTest { @Test fun `custom gaid`() { - every { mockGoogleAdIdManager.getId() } returns null - val actual = identifiersEncoder.encode(listOf(OptableIdentifier.GoogleGaid("customId"))) val expected = listOf("g:customid") assertEquals(expected, actual) @@ -233,36 +224,28 @@ class OptableIdentifiersTest { @Test fun `both gaids, send two`() { - every { mockGoogleAdIdManager.getId() } returns "managerId" - - val actual = identifiersEncoder.encode(listOf(OptableIdentifier.GoogleGaid("customId"))) + val actual = identifiersEncoder.encode(listOf(OptableIdentifier.GoogleGaid("customId")), "managerId") val expected = listOf("g:managerid", "g:customid") assertEquals(expected, actual) } @Test fun `both gaids equals, send only one`() { - every { mockGoogleAdIdManager.getId() } returns "sameId" - - val actual = identifiersEncoder.encode(listOf(OptableIdentifier.GoogleGaid("sameid"))) + val actual = identifiersEncoder.encode(listOf(OptableIdentifier.GoogleGaid("sameid")), "sameId") val expected = listOf("g:sameid") assertEquals(expected, actual) } @Test fun `gaid from manager`() { - every { mockGoogleAdIdManager.getId() } returns "managerId" - - val actual = identifiersEncoder.encode(emptyList()) + val actual = identifiersEncoder.encode(emptyList(), "managerId") val expected = listOf("g:managerid") assertEquals(expected, actual) } @Test fun `gaid from manager null`() { - every { mockGoogleAdIdManager.getId() } returns null - - val actual = identifiersEncoder.encode(emptyList()) + val actual = identifiersEncoder.encode(emptyList(), null) assertEquals(emptyList(), actual) } diff --git a/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt index f0126d5..dad65e9 100644 --- a/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt @@ -5,17 +5,14 @@ import android.util.Log import co.optable.sdk.OptableConfig import com.google.android.gms.ads.identifier.AdvertisingIdClient import io.mockk.* -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) class GoogleAdIdManagerTest { private lateinit var config: OptableConfig @@ -28,6 +25,7 @@ class GoogleAdIdManagerTest { config = mockk { every { context } returns this@GoogleAdIdManagerTest.context every { skipAdvertisingIdDetection } returns false + every { advertisingIdTimeoutMs } returns 1_000 } manager = GoogleAdIdManager(config) @@ -36,151 +34,83 @@ class GoogleAdIdManagerTest { every { Log.w(any(), any()) } returns 0 mockkStatic(AdvertisingIdClient::class) - - resetCompanionState() } @After fun teardown() { unmockkAll() - resetCompanionState() } - @Test - fun `getId returns null initially when nothing is fetched`() { - assertNull(manager.getId()) + private fun adInfo(adId: String, limitAdTracking: Boolean = false) = mockk { + every { id } returns adId + every { isLimitAdTrackingEnabled } returns limitAdTracking } @Test - fun `fetchAdvertisingId completes deferred with null when skip detection is true`() = runTest { - every { config.skipAdvertisingIdDetection } returns true + fun `getId returns the ad id`() = runBlocking { + every { AdvertisingIdClient.getAdvertisingIdInfo(context) } returns adInfo("12345-abcde") - manager.fetchAdvertisingId() + assertEquals("12345-abcde", manager.getId()) + } - assertNull(manager.deferredTask.await()) + @Test + fun `getId reads the ad id on every call`() = runBlocking { + every { AdvertisingIdClient.getAdvertisingIdInfo(context) } returnsMany listOf( + adInfo("first-id"), + adInfo("reset-id"), + adInfo("reset-id", limitAdTracking = true), + ) + + assertEquals("first-id", manager.getId()) + assertEquals("reset-id", manager.getId()) assertNull(manager.getId()) - - verify(exactly = 0) { AdvertisingIdClient.getAdvertisingIdInfo(any()) } + verify(exactly = 3) { AdvertisingIdClient.getAdvertisingIdInfo(context) } } @Test - fun `fetchAdvertisingId successfully fetches ad id and updates state`() = runTest { - val expectedAdId = "12345-abcde" - val mockInfo = mockk { - every { id } returns expectedAdId - every { isLimitAdTrackingEnabled } returns false - } - every { AdvertisingIdClient.getAdvertisingIdInfo(context) } returns mockInfo + fun `getId returns null when limit ad tracking is enabled`() = runBlocking { + every { AdvertisingIdClient.getAdvertisingIdInfo(context) } returns adInfo("12345-abcde", limitAdTracking = true) - manager.fetchAdvertisingId() - - assertEquals(expectedAdId, manager.deferredTask.await()) - assertEquals(expectedAdId, manager.getId()) + assertNull(manager.getId()) } @Test - fun `fetchAdvertisingId sets id but getId returns null when limit ad tracking is enabled`() = runTest { - val expectedAdId = "12345-abcde" - val mockInfo = mockk { - every { id } returns expectedAdId - every { isLimitAdTrackingEnabled } returns true - } - every { AdvertisingIdClient.getAdvertisingIdInfo(context) } returns mockInfo - - manager.fetchAdvertisingId() - - assertEquals(expectedAdId, manager.deferredTask.await()) + fun `getId does not read the ad id when skip detection is true`() = runBlocking { + every { config.skipAdvertisingIdDetection } returns true assertNull(manager.getId()) + verify(exactly = 0) { AdvertisingIdClient.getAdvertisingIdInfo(any()) } } @Test - fun `fetchAdvertisingId completes with null gracefully when exception occurs`() = runTest { - every { AdvertisingIdClient.getAdvertisingIdInfo(context) } throws IllegalStateException("Google Play Services not available") + fun `getId does not read the ad id when the timeout is zero`() = runBlocking { + every { config.advertisingIdTimeoutMs } returns 0 - manager.fetchAdvertisingId() - - assertNull(manager.deferredTask.await()) assertNull(manager.getId()) - - verify(exactly = 1) { Log.w("OptableGaidManager", any()) } + verify(exactly = 0) { AdvertisingIdClient.getAdvertisingIdInfo(any()) } } - @Test - fun `deferredTask await blocks until fetchAdvertisingId completes`() = runTest { - val expectedAdId = "async-test-id" - val mockInfo = mockk { - every { id } returns expectedAdId - every { isLimitAdTrackingEnabled } returns false - } - every { AdvertisingIdClient.getAdvertisingIdInfo(context) } returns mockInfo - - val testManager = GoogleAdIdManager(config) - - var awaitCompleted = false - val awaitJob = launch { - testManager.deferredTask.await() - awaitCompleted = true - } - - advanceTimeBy(100) - assertEquals("await() should be suspended until fetchAdvertisingId completes", false, awaitCompleted) - - testManager.fetchAdvertisingId() - - awaitJob.join() + fun `getId returns null gracefully when exception occurs`() = runBlocking { + every { AdvertisingIdClient.getAdvertisingIdInfo(context) } throws IllegalStateException("Google Play Services not available") - assertEquals("await() should complete after fetchAdvertisingId", true, awaitCompleted) - assertEquals(expectedAdId, testManager.getId()) + assertNull(manager.getId()) + verify(exactly = 1) { Log.w("OptableGaidManager", any()) } } @Test - fun `multiple calls to deferredTask await return the same result`() = runTest { - val expectedAdId = "shared-id-12345" - val mockInfo = mockk { - every { id } returns expectedAdId - every { isLimitAdTrackingEnabled } returns false - } - every { AdvertisingIdClient.getAdvertisingIdInfo(context) } returns mockInfo - - val testManager = GoogleAdIdManager(config) - - val results = mutableListOf() - val jobs = List(5) { - launch { - val result = testManager.deferredTask.await() - results.add(result) - } + fun `getId stops waiting after the timeout`() = runBlocking { + every { config.advertisingIdTimeoutMs } returns 50 + every { AdvertisingIdClient.getAdvertisingIdInfo(context) } answers { + Thread.sleep(1_000) + adInfo("slow-id") } - advanceTimeBy(50) - assertEquals("No results should be available yet", 0, results.size) - - testManager.fetchAdvertisingId() - - jobs.forEach { it.join() } - - assertEquals(5, results.size) - results.forEach { result -> - assertEquals(expectedAdId, result) - } + val start = System.currentTimeMillis() + val id = manager.getId() + val elapsed = System.currentTimeMillis() - start - verify(exactly = 1) { AdvertisingIdClient.getAdvertisingIdInfo(context) } - } - - - private fun resetCompanionState() { - try { - val adIdField = GoogleAdIdManager::class.java.getDeclaredField("adId") - adIdField.isAccessible = true - adIdField.set(null, null) - - val limitField = GoogleAdIdManager::class.java.getDeclaredField("limitAdTracking") - limitField.isAccessible = true - limitField.set(null, null) - } catch (e: Exception) { - e.printStackTrace() - } + assertNull(id) + assertTrue("waited $elapsed ms", elapsed < 900) } } 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 871b71c..02a022f 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 @@ -5,8 +5,6 @@ import org.junit.Assert.* 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 @@ -15,12 +13,10 @@ import org.robolectric.annotation.Config class IdentifiersEncoderTest { private lateinit var identifiersEncoder: IdentifiersEncoder - private lateinit var mockGoogleAdIdManager: GoogleAdIdManager @Before fun setUp() { - mockGoogleAdIdManager = mock() - identifiersEncoder = IdentifiersEncoder(mockGoogleAdIdManager) + identifiersEncoder = IdentifiersEncoder() } @Test @@ -45,9 +41,10 @@ class IdentifiersEncoderTest { @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"))) + val ids = identifiersEncoder.encode( + listOf(OptableIdentifier.IPv6("2001:db8::1")), + "38400000-8cf0-11bd-b23e-10b96e40000d", + ) assertEquals( listOf("g:38400000-8cf0-11bd-b23e-10b96e40000d", "i6:2001:db8::1"), @@ -58,9 +55,7 @@ class IdentifiersEncoderTest { @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))) + val ids = identifiersEncoder.encode(listOf(OptableIdentifier.GoogleGaid(deviceGaid)), deviceGaid) assertEquals(listOf("g:$deviceGaid"), ids) } From 00f4559b370b6f18ee6ffe3c574d75aa5d46549b Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 9 Nov 2026 18:43:38 -0700 Subject: [PATCH 2/3] Let skipAdvertisingIdDetection be changed at any time It was an internal val, so an app had to create a new OptableConfig and OptableSDK when the user granted or withdrew consent for the advertising ID. Make it a public var, like origin and consents and like the iOS SDK; since the GAID is now read on every call, a change applies from the next call. Both advertising ID settings are @Volatile, as calls read them on a background thread. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- .../src/main/java/co/optable/sdk/OptableConfig.kt | 5 +++-- .../src/test/java/co/optable/sdk/OptableConfigTest.kt | 11 +++++++++++ .../java/co/optable/sdk/core/GoogleAdIdManagerTest.kt | 11 +++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index de9438e..1dc6fea 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,8 @@ that production DCNs only listen to TLS traffic. The `insecure: true` option is developers running the DCN locally for testing. - `apiKey` An optional API key for authentication. If the API Endpoint is enabled as private, a Service Account API key will be required. - `customUserAgent` An optional custom user agent string for network requests. -- `skipAdvertisingIdDetection` Boolean flag to skip the detection of advertising IDs. Default is false. +- `skipAdvertisingIdDetection` Boolean flag to skip the detection of advertising IDs. Default is false. Can be +changed at any time, for example when the user grants or withdraws consent, and applies from the next call. - `consents` Optional `OptableConsents` object for providing custom consent information. If not provided, default values will be used. - `origin` An optional value for the HTTP `Origin` header sent with Optable API requests. Unrelated to `originSlug`. - `cacheTtl` The time to live, in seconds, of the targeting data cached by the `targeting` API. Default diff --git a/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt b/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt index 8baaa00..12f7aa2 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt @@ -14,6 +14,7 @@ import android.content.Context * @param apiKey An optional API key for authentication. If the API Endpoint is enabled as private, a Service Account API key will be required. * @param customUserAgent An optional custom user agent string for network requests. * @param skipAdvertisingIdDetection Boolean flag to skip the detection of advertising IDs. Default is false. + * Settable at any time, for example when the user grants or withdraws consent; applies from the next call. * @param consents Optional `OptableConsents` object for providing custom consent information. If not provided, default values will be used. * @param origin An optional value for the HTTP `Origin` header sent with Optable API requests. Unrelated to `originSlug`. * Settable after construction, which is how Java callers set it without passing every preceding parameter. @@ -33,11 +34,11 @@ class OptableConfig @JvmOverloads constructor( internal val insecure: Boolean = false, internal val apiKey: String? = null, internal val customUserAgent: String? = null, - internal val skipAdvertisingIdDetection: Boolean = false, + @Volatile var skipAdvertisingIdDetection: Boolean = false, var consents: OptableConsents = OptableConsents(), var origin: String? = null, internal val cacheTtl: Long = DEFAULT_TARGETING_CACHE_TTL_SECONDS, - var advertisingIdTimeoutMs: Long = DEFAULT_ADVERTISING_ID_TIMEOUT_MS, + @Volatile var advertisingIdTimeoutMs: Long = DEFAULT_ADVERTISING_ID_TIMEOUT_MS, ) { companion object { diff --git a/android_sdk/src/test/java/co/optable/sdk/OptableConfigTest.kt b/android_sdk/src/test/java/co/optable/sdk/OptableConfigTest.kt index 415afa6..19205dc 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableConfigTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableConfigTest.kt @@ -79,6 +79,17 @@ class OptableConfigTest { assertEquals(250L, config.advertisingIdTimeoutMs) } + @Test + fun `advertising ID settings should be settable after construction`() { + val config = OptableConfig(mockContext, "test-tenant", "test-slug") + + config.skipAdvertisingIdDetection = true + config.advertisingIdTimeoutMs = 250L + + assertTrue(config.skipAdvertisingIdDetection) + assertEquals(250L, config.advertisingIdTimeoutMs) + } + @Test fun `getBaseUrl should return HTTPS url when insecure is false`() { val config = OptableConfig( diff --git a/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt index dad65e9..837733f 100644 --- a/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt @@ -82,6 +82,17 @@ class GoogleAdIdManagerTest { verify(exactly = 0) { AdvertisingIdClient.getAdvertisingIdInfo(any()) } } + @Test + fun `changing skip detection applies from the next call`() = runBlocking { + every { AdvertisingIdClient.getAdvertisingIdInfo(context) } returns adInfo("12345-abcde") + + assertEquals("12345-abcde", manager.getId()) + every { config.skipAdvertisingIdDetection } returns true + assertNull(manager.getId()) + every { config.skipAdvertisingIdDetection } returns false + assertEquals("12345-abcde", manager.getId()) + } + @Test fun `getId does not read the ad id when the timeout is zero`() = runBlocking { every { config.advertisingIdTimeoutMs } returns 0 From 4e29d5d7b852772afc00b99bf1cb57436cb6e575 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Wed, 11 Nov 2026 09:45:15 -0700 Subject: [PATCH 3/3] Wait at least 1 ms for the Google Advertising ID A wait of zero or less skipped reading the GAID, so no ID could be added. Raise any value below 1 ms to 1 ms instead: reading the GAID always takes some time, typically tens of milliseconds depending on the device, which the docs now say. skipAdvertisingIdDetection is the way to never add it. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++-- .../src/main/java/co/optable/sdk/OptableConfig.kt | 7 ++++--- .../java/co/optable/sdk/core/GoogleAdIdManager.kt | 14 +++++++++----- .../co/optable/sdk/core/GoogleAdIdManagerTest.kt | 8 +++++--- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1dc6fea..25d9e17 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,9 @@ changed at any time, for example when the user grants or withdraws consent, and - `cacheTtl` The time to live, in seconds, of the targeting data cached by the `targeting` API. Default value is 24 hours. See [Caching Targeting Data](#caching-targeting-data). - `advertisingIdTimeoutMs` How long, in milliseconds, an `identify` or `targeting` call waits for the Google Advertising -ID before it is sent without it. Default value is 100. Zero or less means don't wait, so the Google Advertising ID is -never added automatically. +ID before it is sent without it. Default value is 100, and the minimum is 1: a smaller value waits 1 millisecond. +Expect reading the Google Advertising ID to take tens of milliseconds, depending on the device, so a much lower value +means most calls are sent without it. To never add the Google Advertising ID, use `skipAdvertisingIdDetection`. ```kotlin val config = OptableConfig( diff --git a/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt b/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt index 12f7aa2..009698d 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt @@ -21,9 +21,10 @@ import android.content.Context * @param cacheTtl The targeting cache time to live in seconds. Default value is 24 hours. * A value of zero or less disables the cache. * @param advertisingIdTimeoutMs How long, in milliseconds, an `identify` or `targeting` call waits for the Google - * Advertising ID before it is sent without it. Default value is 100. Zero or less means don't wait, so the - * Advertising ID is never added automatically; there is no setting that waits forever. Settable after construction, - * like `origin`. + * Advertising ID before it is sent without it. Default value is 100. The minimum is 1: a smaller value waits 1 ms. + * Expect reading the Advertising ID to take tens of milliseconds, depending on the device, so a much lower value + * means most calls are sent without it. There is no setting that waits forever; to never add the Advertising ID, + * use `skipAdvertisingIdDetection`. Settable after construction, like `origin`. */ class OptableConfig @JvmOverloads constructor( providedContext: Context, diff --git a/android_sdk/src/main/java/co/optable/sdk/core/GoogleAdIdManager.kt b/android_sdk/src/main/java/co/optable/sdk/core/GoogleAdIdManager.kt index 8790587..d696516 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/GoogleAdIdManager.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/GoogleAdIdManager.kt @@ -13,23 +13,27 @@ internal class GoogleAdIdManager( val config: OptableConfig, ) { + companion object { + const val MIN_TIMEOUT_MS = 1L + } + // getAdvertisingIdInfo() blocks and can't be cancelled, so reads run here and a caller stops waiting after - // config.advertisingIdTimeoutMs, leaving a slow read to finish on its own. + // config.advertisingIdTimeoutMs (at least MIN_TIMEOUT_MS), leaving a slow read to finish on its own. private val readScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) /** * Reads the GAID for one API call. Google says not to cache it, since the user can reset it or opt out at any * time, so it's read every time. Returns null when skipAdvertisingIdDetection is set, the user has opted out, or - * the read fails or takes longer than config.advertisingIdTimeoutMs. A timeout of zero or less means don't wait, - * so the GAID isn't read at all. + * the read fails or takes longer than config.advertisingIdTimeoutMs, which is raised to MIN_TIMEOUT_MS when lower: + * a read always takes some time, so without a wait no GAID could ever be added. */ suspend fun getId(): String? { - if (config.skipAdvertisingIdDetection || config.advertisingIdTimeoutMs <= 0) { + if (config.skipAdvertisingIdDetection) { return null } val read = readScope.async { read() } - return withTimeoutOrNull(config.advertisingIdTimeoutMs) { + return withTimeoutOrNull(config.advertisingIdTimeoutMs.coerceAtLeast(MIN_TIMEOUT_MS)) { read.await() } } diff --git a/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt index 837733f..fc14e64 100644 --- a/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt @@ -94,11 +94,13 @@ class GoogleAdIdManagerTest { } @Test - fun `getId does not read the ad id when the timeout is zero`() = runBlocking { + fun `getId still reads the ad id when the timeout is below the minimum`() = runBlocking { every { config.advertisingIdTimeoutMs } returns 0 + every { AdvertisingIdClient.getAdvertisingIdInfo(context) } returns adInfo("12345-abcde") - assertNull(manager.getId()) - verify(exactly = 0) { AdvertisingIdClient.getAdvertisingIdInfo(any()) } + manager.getId() + + verify(timeout = 1_000, exactly = 1) { AdvertisingIdClient.getAdvertisingIdInfo(context) } } @Test