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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,16 @@ 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
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, 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(
Expand All @@ -157,6 +162,7 @@ val config = OptableConfig(
consents = OptableConsents(),
origin = "https://www.acmeco.com",
cacheTtl = 24 * 60 * 60L,
advertisingIdTimeoutMs = 100L,
)
```

Expand Down
10 changes: 9 additions & 1 deletion android_sdk/src/main/java/co/optable/sdk/OptableConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@ 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.
* @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. 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,
Expand All @@ -29,14 +35,16 @@ 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,
@Volatile 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
Expand Down
18 changes: 6 additions & 12 deletions android_sdk/src/main/java/co/optable/sdk/OptableSDK.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<OptableIdentifier>, listener: OptableResultListener<Unit>) {
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 {
Expand Down Expand Up @@ -157,9 +151,9 @@ class OptableSDK(
listener: OptableResultListener<OptableTargeting>,
) {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,47 @@ 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
const val MIN_TIMEOUT_MS = 1L
}

val deferredTask = CompletableDeferred<String?>()

fun getId(): String? {
if (limitAdTracking == true) {
return null
}
return adId
}

suspend fun fetchAdvertisingId() {
// getAdvertisingIdInfo() blocks and can't be cancelled, so reads run here and a caller stops waiting after
// 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, 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) {
deferredTask.complete(null)
return
return null
}

val id = withContext(Dispatchers.IO) {
withTimeoutOrNull(3_000) {
fetch()
}
val read = readScope.async { read() }
return withTimeoutOrNull(config.advertisingIdTimeoutMs.coerceAtLeast(MIN_TIMEOUT_MS)) {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -31,10 +29,12 @@ internal class IdentifiersEncoder(
}


fun encode(identifiers: List<OptableIdentifier>): List<String> {
/**
* @param googleAdId The device's GAID, added first when not null.
*/
fun encode(identifiers: List<OptableIdentifier>, googleAdId: String? = null): List<String> {
val result = mutableListOf<String>()

val googleAdId = googleAdIdManager.getId()
if (googleAdId != null) {
result.addIfNotNull(GAID, googleAdId, ::normalize)
}
Expand Down
16 changes: 15 additions & 1 deletion android_sdk/src/test/java/co/optable/sdk/OptableConfigTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -74,6 +76,18 @@ class OptableConfigTest {
assertEquals(customConsents, config.consents)
assertEquals("https://www.optable.co", config.origin)
assertEquals(60L, config.cacheTtl)
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,14 +10,10 @@ import java.util.*
class OptableIdentifiersTest {

private lateinit var identifiersEncoder: IdentifiersEncoder
private lateinit var mockGoogleAdIdManager: GoogleAdIdManager

@Before
fun setUp() {
mockGoogleAdIdManager = mockk<GoogleAdIdManager>()
every { mockGoogleAdIdManager.getId() } returns null

identifiersEncoder = IdentifiersEncoder(mockGoogleAdIdManager)
identifiersEncoder = IdentifiersEncoder()
}

@Test
Expand Down Expand Up @@ -224,45 +217,35 @@ 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)
}

@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<String>(), actual)
}

Expand Down
Loading
Loading