diff --git a/android_sdk/build.gradle b/android_sdk/build.gradle index 97d120b..e57bb55 100644 --- a/android_sdk/build.gradle +++ b/android_sdk/build.gradle @@ -60,7 +60,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.9.4" // Advertising ID - implementation 'com.google.android.gms:play-services-ads-identifier:18.2.0' + implementation 'com.google.android.gms:play-services-ads-identifier:18.3.0' // Unit tests testImplementation("junit:junit:4.13.2") 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 b968c0b..874e52c 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableSDK.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableSDK.kt @@ -38,8 +38,8 @@ class OptableSDK( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + ceh) init { - if (!config.skipAdvertisingIdDetection) { - googleAdIdManager.updateAdvertisingId() + scope.launch { + googleAdIdManager.fetchAdvertisingId() } } @@ -49,6 +49,7 @@ class OptableSDK( */ fun identify(ids: List, listener: OptableResultListener) { scope.launch { + googleAdIdManager.deferredTask.await() val encodedIds = identifiersEncoder.encode(ids) val response = networkClient.identify(encodedIds) @@ -135,6 +136,7 @@ class OptableSDK( */ fun targeting(ids: List, listener: OptableResultListener) { scope.launch { + googleAdIdManager.deferredTask.await() val ids = identifiersEncoder.encode(ids) val response = networkClient.targeting(ids) 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 8993809..5ffb46a 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 @@ -1,10 +1,12 @@ 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.GlobalScope -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull internal class GoogleAdIdManager( val config: OptableConfig, @@ -15,20 +17,7 @@ internal class GoogleAdIdManager( private var limitAdTracking: Boolean? = null } - fun updateAdvertisingId() { - GlobalScope.launch { - var adInfo: AdvertisingIdClient.Info? = null - try { - adInfo = AdvertisingIdClient.getAdvertisingIdInfo(config.context) - } catch (_: Exception) { - } - - MainScope().launch { - adId = adInfo?.id - limitAdTracking = adInfo?.isLimitAdTrackingEnabled - } - } - } + val deferredTask = CompletableDeferred() fun getId(): String? { if (limitAdTracking == true) { @@ -37,4 +26,30 @@ internal class GoogleAdIdManager( return adId } + suspend fun fetchAdvertisingId() { + if (config.skipAdvertisingIdDetection) { + deferredTask.complete(null) + return + } + + val id = withContext(Dispatchers.IO) { + withTimeoutOrNull(3_000) { + fetch() + } + } + deferredTask.complete(id) + } + + private fun fetch(): String? { + try { + val adInfo = AdvertisingIdClient.getAdvertisingIdInfo(config.context) + adId = adInfo.id + limitAdTracking = adInfo.isLimitAdTrackingEnabled + return adInfo.id + } catch (exception: Exception) { + Log.w("OptableGaidManager", "Failed to fetch advertising ID: " + exception.message) + } + return null + } + } 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 8a606a7..bd6dda0 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 @@ -34,7 +34,11 @@ internal class IdentifiersEncoder( fun encode(identifiers: List): List { val result = mutableListOf() - var containsCustomGaid = false + val googleAdId = googleAdIdManager.getId() + if (googleAdId != null) { + result.addIfNotNull(GAID, googleAdId, ::trim) + } + for (identifier in identifiers) { when (identifier) { is OptableIdentifier.Email -> result.addIfNotNull(EMAIL, identifier.value, ::encrypt) @@ -51,8 +55,9 @@ internal class IdentifiersEncoder( is OptableIdentifier.Utiq -> result.addIfNotNull(UTIQ, identifier.value, ::normalize) is OptableIdentifier.GoogleGaid -> { - result.addIfNotNull(GAID, identifier.value, ::normalize) - containsCustomGaid = true + if (googleAdId == null) { + result.addIfNotNull(GAID, identifier.value, ::normalize) + } } is OptableIdentifier.Custom -> { @@ -67,10 +72,6 @@ internal class IdentifiersEncoder( } } - val googleAdId = googleAdIdManager.getId() - if (!containsCustomGaid && googleAdId != null) { - result.addIfNotNull(GAID, googleAdId, ::trim) - } return result } 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 1d5c6cd..d70aa77 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt @@ -178,13 +178,22 @@ class OptableIdentifiersTest { @Test fun `custom gaid`() { - every { mockGoogleAdIdManager.getId() } returns "managerId" + every { mockGoogleAdIdManager.getId() } returns null val actual = identifiersEncoder.encode(listOf(OptableIdentifier.GoogleGaid("customId"))) val expected = listOf("g:customid") assertEquals(expected, actual) } + @Test + fun `both gaids, manager win`() { + every { mockGoogleAdIdManager.getId() } returns "managerId" + + val actual = identifiersEncoder.encode(listOf(OptableIdentifier.GoogleGaid("customId"))) + val expected = listOf("g:managerId") + assertEquals(expected, actual) + } + @Test fun `gaid from manager`() { every { mockGoogleAdIdManager.getId() } returns "managerId" 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 380c541..6cf0dc2 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt @@ -45,8 +45,10 @@ class OptableSDKTest { mockkStatic(Log::class) every { Log.e(any(), any()) } returns 0 every { Log.e(any(), any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 mockkStatic(Base64::class) + every { Base64.encodeToString(any(), any()) } returns "mockedBase64String" sdk = OptableSDK(mockConfig) 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 new file mode 100644 index 0000000..f0126d5 --- /dev/null +++ b/android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt @@ -0,0 +1,186 @@ +package co.optable.sdk.core + +import android.content.Context +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 org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GoogleAdIdManagerTest { + + private lateinit var config: OptableConfig + private lateinit var context: Context + private lateinit var manager: GoogleAdIdManager + + @Before + fun setup() { + context = mockk() + config = mockk { + every { context } returns this@GoogleAdIdManagerTest.context + every { skipAdvertisingIdDetection } returns false + } + manager = GoogleAdIdManager(config) + + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + 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()) + } + + @Test + fun `fetchAdvertisingId completes deferred with null when skip detection is true`() = runTest { + every { config.skipAdvertisingIdDetection } returns true + + manager.fetchAdvertisingId() + + assertNull(manager.deferredTask.await()) + assertNull(manager.getId()) + + verify(exactly = 0) { AdvertisingIdClient.getAdvertisingIdInfo(any()) } + } + + @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 + + manager.fetchAdvertisingId() + + assertEquals(expectedAdId, manager.deferredTask.await()) + assertEquals(expectedAdId, 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()) + + assertNull(manager.getId()) + } + + @Test + fun `fetchAdvertisingId completes with null gracefully when exception occurs`() = runTest { + every { AdvertisingIdClient.getAdvertisingIdInfo(context) } throws IllegalStateException("Google Play Services not available") + + manager.fetchAdvertisingId() + + assertNull(manager.deferredTask.await()) + assertNull(manager.getId()) + + verify(exactly = 1) { Log.w("OptableGaidManager", 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() + + assertEquals("await() should complete after fetchAdvertisingId", true, awaitCompleted) + assertEquals(expectedAdId, testManager.getId()) + } + + @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) + } + } + + 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) + } + + 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() + } + } +}