From 0b57225d773e78c70cd5c174b35d11887a2e4831 Mon Sep 17 00:00:00 2001 From: Ian Bird Date: Wed, 12 Jun 2024 10:49:48 +0100 Subject: [PATCH] [Prebid] Implement Prebid integration to keep external IDs configured --- dev-app/build.gradle | 5 +- .../main/java/com/uid2/dev/DevApplication.kt | 13 ++ gradle/libs.versions.toml | 4 + prebid/build.gradle | 35 ++++ prebid/gradle.properties | 3 + prebid/src/main/AndroidManifest.xml | 2 + .../main/java/com/uid2/prebid/UID2Prebid.kt | 132 ++++++++++++++ .../java/com/uid2/prebid/UID2PrebidTest.kt | 172 ++++++++++++++++++ sdk/build.gradle | 4 + sdk/src/main/java/com/uid2/InternalUID2Api.kt | 22 +++ sdk/src/main/java/com/uid2/UID2Manager.kt | 2 +- sdk/src/main/java/com/uid2/utils/Logger.kt | 14 +- settings.gradle | 1 + 13 files changed, 402 insertions(+), 7 deletions(-) create mode 100644 prebid/build.gradle create mode 100644 prebid/gradle.properties create mode 100644 prebid/src/main/AndroidManifest.xml create mode 100644 prebid/src/main/java/com/uid2/prebid/UID2Prebid.kt create mode 100644 prebid/src/test/java/com/uid2/prebid/UID2PrebidTest.kt create mode 100644 sdk/src/main/java/com/uid2/InternalUID2Api.kt diff --git a/dev-app/build.gradle b/dev-app/build.gradle index b5adc00..1461a48 100644 --- a/dev-app/build.gradle +++ b/dev-app/build.gradle @@ -42,7 +42,8 @@ composeCompiler { } dependencies { - implementation project(path: ':sdk') + implementation project(":sdk") + implementation project(":prebid") implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle) @@ -55,6 +56,8 @@ dependencies { implementation(libs.okhttp.core) + implementation(libs.prebid) + debugImplementation(libs.compose.tooling) debugImplementation(libs.compose.test.manifest) } diff --git a/dev-app/src/main/java/com/uid2/dev/DevApplication.kt b/dev-app/src/main/java/com/uid2/dev/DevApplication.kt index 0f25c45..703509b 100644 --- a/dev-app/src/main/java/com/uid2/dev/DevApplication.kt +++ b/dev-app/src/main/java/com/uid2/dev/DevApplication.kt @@ -2,9 +2,14 @@ package com.uid2.dev import android.app.Application import android.os.StrictMode +import android.util.Log import com.uid2.UID2Manager +import com.uid2.prebid.UID2Prebid +import org.prebid.mobile.PrebidMobile class DevApplication : Application() { + private lateinit var prebid: UID2Prebid + override fun onCreate() { super.onCreate() @@ -15,6 +20,12 @@ class DevApplication : Application() { // Alternatively, we could initialise the UID2Manager with our own custom NetworkSession... // UID2Manager.init(this, INTEG_SERVER_URL, OkNetworkSession(), true) + // Create the Prebid integration and allow it to start observing the UID2Manager instance. + PrebidMobile.initializeSdk(this) { Log.i(TAG, "Prebid: $it") } + prebid = UID2Prebid().apply { + initialize() + } + // For the development app, we will enable a strict thread policy to ensure we have suitable visibility of any // issues within the SDK. enableStrictMode() @@ -32,6 +43,8 @@ class DevApplication : Application() { } private companion object { + const val TAG = "DevApplication" + const val INTEG_SERVER_URL = "https://operator-integ.uidapi.com" } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da352a1..feb5f88 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ compose-tooling = "1.6.7" gma = "23.1.0" ima = "3.33.0" mockkVersion = "1.13.11" +prebid = "2.2.1" [libraries] core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -38,6 +39,9 @@ androidx-media = { group = "androidx.media", name = "media", version = "1.7.0" } androidx-browser = { group = "androidx.browser", name = "browser", version = "1.8.0" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version = "1.9.0" } +# Prebid +prebid = { group = "org.prebid", name = "prebid-mobile-sdk", version.ref = "prebid" } + # Testing junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines-version" } diff --git a/prebid/build.gradle b/prebid/build.gradle new file mode 100644 index 0000000..edba418 --- /dev/null +++ b/prebid/build.gradle @@ -0,0 +1,35 @@ +plugins { + alias libs.plugins.androidLibrary + alias libs.plugins.kotlinAndroid + alias libs.plugins.dokka + alias libs.plugins.mavenPublish +} + +apply from: rootProject.file("$rootDir/common.gradle") + +android { + namespace 'com.uid2.prebid' + defaultConfig { + minSdk 19 + } + + kotlin { + explicitApi() + } + + kotlinOptions { + freeCompilerArgs += [ "-opt-in=com.uid2.InternalUID2Api" ] + } +} + +dependencies { + implementation project(":sdk") + implementation(libs.prebid) + + testImplementation(libs.junit) + + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) + + testImplementation(libs.json) +} diff --git a/prebid/gradle.properties b/prebid/gradle.properties new file mode 100644 index 0000000..15bbc87 --- /dev/null +++ b/prebid/gradle.properties @@ -0,0 +1,3 @@ +POM_NAME=UID2 Android SDK (Prebid) +POM_ARTIFACT_ID=uid2-android-sdk-prebid +POM_DESCRIPTION=An SDK for integrating UID2 and Prebid into Android applications. diff --git a/prebid/src/main/AndroidManifest.xml b/prebid/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/prebid/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/prebid/src/main/java/com/uid2/prebid/UID2Prebid.kt b/prebid/src/main/java/com/uid2/prebid/UID2Prebid.kt new file mode 100644 index 0000000..0ddd4f5 --- /dev/null +++ b/prebid/src/main/java/com/uid2/prebid/UID2Prebid.kt @@ -0,0 +1,132 @@ +package com.uid2.prebid + +import com.uid2.UID2Manager +import com.uid2.UID2ManagerState +import com.uid2.UID2ManagerState.Established +import com.uid2.UID2ManagerState.Refreshed +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.prebid.mobile.ExternalUserId +import org.prebid.mobile.PrebidMobile + +/** + * Interface to wrap access to [PrebidMobile]. This is used to improve testability, rather than having the [UID2Prebid] + * access it via static methods. + */ +internal fun interface PrebidExternalUserIdInteractor { + operator fun invoke(ids: List) +} + +/** + * Prebid integration that will observe a given [UID2Manager] instance and update Prebid when a new [ExternalUserId] is + * available. After creating the instance, a consumer must explicitly call [initialize] for this instance to start + * observing changes. + */ +public class UID2Prebid internal constructor( + private val manager: UID2Manager, + private val externalUserIdFactory: () -> List, + private val prebidInteractor: PrebidExternalUserIdInteractor, + dispatcher: CoroutineDispatcher, +) { + + // We redirect to the logger owned by the UID2Manager, as it's been configured correctly. + private val logger = manager.logger + + /** + * Constructor. + * + * @param manager The [UID2Manager] instance to be observed. + * @param externalUserIdFactory A factory that will allow the consumer to add any other [ExternalUserId]s that should + * also be included, rather than just a single list containing only UID2's instance. + */ + @JvmOverloads + public constructor( + manager: UID2Manager = UID2Manager.getInstance(), + externalUserIdFactory: () -> List = { emptyList() }, + ) : this( + manager, + externalUserIdFactory, + PrebidExternalUserIdInteractor { ids -> PrebidMobile.setExternalUserIds(ids) }, + Dispatchers.Default, + ) + + private val scope = CoroutineScope(dispatcher + SupervisorJob()) + + /** + * Initializes the integration which will start observing the associated [UID2Manager] instance for changes in the + * availability of the advertising token. As the token is refreshed, this will automatically update Prebid's list + * of ExternalUserIds. + */ + public fun initialize() { + // Once the UID2Manager instance has been initialized, we will start observing it for changes. + manager.addOnInitializedListener { + updateExternalUserId(manager.getAdvertisingToken(), "Initialized") + observeIdentityChanges() + } + } + + /** + * Releases this instance, to not be used again. + */ + public fun release() { + scope.cancel() + } + + /** + * Returns the list of UID2 scoped [ExternalUserId]s. + */ + public fun getExternalUserIdList(): List { + return getExternalUserIdList(manager.getAdvertisingToken()) + } + + /** + * Observes changes in the [UID2ManagerState] of the [UID2Manager] to update Prebid's [ExternalUserId]s. + */ + private fun observeIdentityChanges() { + scope.launch { + manager.state.collect { state -> + when (state) { + is Established -> updateExternalUserId(state.identity.advertisingToken, "Identity Established") + is Refreshed -> updateExternalUserId(state.identity.advertisingToken, "Identity Refreshed") + else -> updateExternalUserId(null, "Identity Changed: $state") + } + } + } + } + + /** + * Updates Prebid's [ExternalUserId]s. + */ + private fun updateExternalUserId(advertisingToken: String?, reason: String) { + // We should set the external user ids to contain both our own UID2 specific one, along with any provided + // externally. + logger.i(TAG) { "Updating Prebid: $reason" } + val userIds = getExternalUserIdList(advertisingToken) + prebidInteractor(externalUserIdFactory() + userIds) + } + + /** + * Converts the given token to the associated list of [ExternalUserId]s. + */ + private fun getExternalUserIdList(advertisingToken: String?): List { + return advertisingToken?.toExternalUserIdList() ?: emptyList() + } + + /** + * Extension function to build a list containing the single [ExternalUserId] that is associated with UID2. + */ + private fun String.toExternalUserIdList(): List { + return listOf( + ExternalUserId(USER_ID_SOURCE, this, null, null), + ) + } + + private companion object { + const val TAG = "UID2Prebid" + const val USER_ID_SOURCE = "uidapi.com" + } +} diff --git a/prebid/src/test/java/com/uid2/prebid/UID2PrebidTest.kt b/prebid/src/test/java/com/uid2/prebid/UID2PrebidTest.kt new file mode 100644 index 0000000..6fc29a0 --- /dev/null +++ b/prebid/src/test/java/com/uid2/prebid/UID2PrebidTest.kt @@ -0,0 +1,172 @@ +package com.uid2.prebid + +import com.uid2.UID2Manager +import com.uid2.UID2ManagerState +import com.uid2.data.UID2Identity +import com.uid2.utils.Logger +import io.mockk.every +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.prebid.mobile.ExternalUserId + +@OptIn(ExperimentalCoroutinesApi::class) +class UID2PrebidTest { + @get:Rule + val mockkRule = MockKRule(this) + + private val testDispatcher: TestDispatcher = StandardTestDispatcher() + + private val manager = mockk() + private var currentAdvertisingToken = "secret token" + private val state = MutableStateFlow(UID2ManagerState.Loading) + private val logger = mockk(relaxed = true) + + private val prebidExternalUserIdInteractor = FakePrebidExternalUserIdInteractor() + + @Before + fun before() { + every { manager.addOnInitializedListener(any()) }.answers { + // Invoke the listener immediately. + val listener = firstArg() as (() -> Unit) + listener() + + return@answers manager + } + + every { manager.getAdvertisingToken() }.returns(currentAdvertisingToken) + every { manager.state }.returns(state) + every { manager.logger }.returns(logger) + } + + @Test + fun `initialises user id on creation`() { + val prebid = withPrebid().apply { + initialize() + } + + // Verify that immediately after being initialized, the available advertising token is set on Prebid. + assertEquals(1, prebidExternalUserIdInteractor.lastIds.size) + assertEquals(currentAdvertisingToken, prebidExternalUserIdInteractor.lastIds[0].identifier) + } + + @Test + fun `observes changes to advertising token`() = runTest(testDispatcher) { + val prebid = withPrebid().apply { + initialize() + } + + // Start with an established token. + val newToken1 = "established-token" + state.emit(withEstablished(newToken1)) + advanceUntilIdle() + + // Verify it was set on Prebid. + prebidExternalUserIdInteractor.assertLastToken(newToken1) + + // Refresh the token. + val newToken2 = "refreshed-token-1" + state.emit(withRefreshed(newToken2)) + advanceUntilIdle() + + // Verify it was set on Prebid. + prebidExternalUserIdInteractor.assertLastToken(newToken2) + + // Refresh the token again. + val newToken3 = "refreshed-token-2" + state.emit(withRefreshed(newToken3)) + advanceUntilIdle() + + // Verify it was set on Prebid. + prebidExternalUserIdInteractor.assertLastToken(newToken3) + } + + @Test + fun `removes id when token invalid`() = runTest(testDispatcher) { + val prebid = withPrebid().apply { + initialize() + } + + listOf( + UID2ManagerState.Expired(withIdentity("")), + UID2ManagerState.Invalid, + UID2ManagerState.RefreshExpired, + UID2ManagerState.OptOut, + ).forEach { managerState -> + + // Start from an established state. + val token = "established" + state.emit(withEstablished(token)) + advanceUntilIdle() + + // Verify that it's been set on Prebid. + prebidExternalUserIdInteractor.assertLastToken(token) + + // Emit the new state. + state.emit(managerState) + advanceUntilIdle() + + // Verify that previous IDs have been removed. + prebidExternalUserIdInteractor.assertNoIds() + } + } + + private fun withPrebid(): UID2Prebid { + return UID2Prebid( + manager = manager, + externalUserIdFactory = { emptyList() }, + prebidInteractor = prebidExternalUserIdInteractor, + dispatcher = testDispatcher, + ) + } + + private fun withEstablished(advertisingToken: String) = UID2ManagerState.Established( + withIdentity(advertisingToken), + ) + + private fun withRefreshed(advertisingToken: String) = UID2ManagerState.Refreshed( + withIdentity(advertisingToken), + ) + + private fun withIdentity(advertisingToken: String) = UID2Identity( + advertisingToken = advertisingToken, + refreshToken = "", + identityExpires = 0, + refreshFrom = 0, + refreshExpires = 0, + refreshResponseKey = "", + ) + + private fun FakePrebidExternalUserIdInteractor.assertLastToken(advertisingToken: String) { + assertTrue(lastIds.isNotEmpty()) + lastIds.last().let { + assertEquals(advertisingToken, it.identifier) + assertEquals("uidapi.com", it.source) + assertNull(it.atype) + assertNull(it.ext) + } + } + + private fun FakePrebidExternalUserIdInteractor.assertNoIds() { + assertTrue(lastIds.isEmpty()) + } +} + +private class FakePrebidExternalUserIdInteractor : PrebidExternalUserIdInteractor { + var lastIds: List = emptyList() + + override fun invoke(ids: List) { + lastIds = ids + } +} diff --git a/sdk/build.gradle b/sdk/build.gradle index bde508f..4ab9642 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -30,6 +30,10 @@ android { kotlin { explicitApi() } + + kotlinOptions { + freeCompilerArgs += [ "-opt-in=com.uid2.InternalUID2Api" ] + } } dependencies { diff --git a/sdk/src/main/java/com/uid2/InternalUID2Api.kt b/sdk/src/main/java/com/uid2/InternalUID2Api.kt new file mode 100644 index 0000000..0adbba9 --- /dev/null +++ b/sdk/src/main/java/com/uid2/InternalUID2Api.kt @@ -0,0 +1,22 @@ +package com.uid2 + +/** + * Marks declarations that are making use of an internal UID2 API that are likely to change in the future. + */ +@MustBeDocumented +@Retention(value = AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.ANNOTATION_CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + AnnotationTarget.LOCAL_VARIABLE, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.TYPEALIAS, +) +@RequiresOptIn(level = RequiresOptIn.Level.WARNING) +public annotation class InternalUID2Api diff --git a/sdk/src/main/java/com/uid2/UID2Manager.kt b/sdk/src/main/java/com/uid2/UID2Manager.kt index 3d631cc..c5b3ed3 100644 --- a/sdk/src/main/java/com/uid2/UID2Manager.kt +++ b/sdk/src/main/java/com/uid2/UID2Manager.kt @@ -93,7 +93,7 @@ public class UID2Manager internal constructor( private val inputUtils: InputUtils, defaultDispatcher: CoroutineDispatcher, initialAutomaticRefreshEnabled: Boolean, - private val logger: Logger, + @property:InternalUID2Api public val logger: Logger, ) { private val scope = CoroutineScope(defaultDispatcher + SupervisorJob()) diff --git a/sdk/src/main/java/com/uid2/utils/Logger.kt b/sdk/src/main/java/com/uid2/utils/Logger.kt index 5966658..7ae0b84 100644 --- a/sdk/src/main/java/com/uid2/utils/Logger.kt +++ b/sdk/src/main/java/com/uid2/utils/Logger.kt @@ -1,30 +1,34 @@ package com.uid2.utils import android.util.Log +import com.uid2.InternalUID2Api /** * Simple logger class that wraps Android's [Log]. + * + * @suppress */ -internal class Logger(private val isEnabled: Boolean = false) { - fun v(tag: String, throwable: Throwable? = null, message: () -> String = { "" }) { +@InternalUID2Api +public class Logger(private val isEnabled: Boolean = false) { + public fun v(tag: String, throwable: Throwable? = null, message: () -> String = { "" }) { if (isEnabled) { Log.v(tag, message(), throwable) } } - fun d(tag: String, throwable: Throwable? = null, message: () -> String = { "" }) { + public fun d(tag: String, throwable: Throwable? = null, message: () -> String = { "" }) { if (isEnabled) { Log.d(tag, message(), throwable) } } - fun i(tag: String, throwable: Throwable? = null, message: () -> String = { "" }) { + public fun i(tag: String, throwable: Throwable? = null, message: () -> String = { "" }) { if (isEnabled) { Log.i(tag, message(), throwable) } } - fun e(tag: String, throwable: Throwable? = null, message: () -> String = { "" }) { + public fun e(tag: String, throwable: Throwable? = null, message: () -> String = { "" }) { Log.e(tag, message(), throwable) } } diff --git a/settings.gradle b/settings.gradle index 86b1a97..2b0ebfa 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,3 +22,4 @@ include ':securesignals-ima' include ':securesignals-gma' include ':securesignals-ima-dev-app' include ':securesignals-gma-dev-app' +include ':prebid'