Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android_sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 4 additions & 2 deletions android_sdk/src/main/java/co/optable/sdk/OptableSDK.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ class OptableSDK(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + ceh)

init {
if (!config.skipAdvertisingIdDetection) {
googleAdIdManager.updateAdvertisingId()
scope.launch {
googleAdIdManager.fetchAdvertisingId()
}
}

Expand All @@ -49,6 +49,7 @@ class OptableSDK(
*/
fun identify(ids: List<OptableIdentifier>, listener: OptableResultListener<Unit>) {
scope.launch {
googleAdIdManager.deferredTask.await()
val encodedIds = identifiersEncoder.encode(ids)
val response = networkClient.identify(encodedIds)

Expand Down Expand Up @@ -135,6 +136,7 @@ class OptableSDK(
*/
fun targeting(ids: List<OptableIdentifier>, listener: OptableResultListener<OptableTargeting>) {
scope.launch {
googleAdIdManager.deferredTask.await()
val ids = identifiersEncoder.encode(ids)
val response = networkClient.targeting(ids)

Expand Down
49 changes: 32 additions & 17 deletions android_sdk/src/main/java/co/optable/sdk/core/GoogleAdIdManager.kt
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<String?>()

fun getId(): String? {
if (limitAdTracking == true) {
Expand All @@ -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
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ internal class IdentifiersEncoder(
fun encode(identifiers: List<OptableIdentifier>): List<String> {
val result = mutableListOf<String>()

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)
Expand All @@ -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 -> {
Expand All @@ -67,10 +72,6 @@ internal class IdentifiersEncoder(
}
}

val googleAdId = googleAdIdManager.getId()
if (!containsCustomGaid && googleAdId != null) {
result.addIfNotNull(GAID, googleAdId, ::trim)
}

return result
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions android_sdk/src/test/java/co/optable/sdk/OptableSDKTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()) } returns 0

mockkStatic(Base64::class)

every { Base64.encodeToString(any(), any()) } returns "mockedBase64String"

sdk = OptableSDK(mockConfig)
Expand Down
186 changes: 186 additions & 0 deletions android_sdk/src/test/java/co/optable/sdk/core/GoogleAdIdManagerTest.kt
Original file line number Diff line number Diff line change
@@ -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<String>()) } 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<AdvertisingIdClient.Info> {
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<AdvertisingIdClient.Info> {
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<String>()) }
}


@Test
fun `deferredTask await blocks until fetchAdvertisingId completes`() = runTest {
val expectedAdId = "async-test-id"
val mockInfo = mockk<AdvertisingIdClient.Info> {
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<AdvertisingIdClient.Info> {
every { id } returns expectedAdId
every { isLimitAdTrackingEnabled } returns false
}
every { AdvertisingIdClient.getAdvertisingIdInfo(context) } returns mockInfo

val testManager = GoogleAdIdManager(config)

val results = mutableListOf<String?>()
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()
}
}
}
Loading