From 094ad409cd213091f194476ec37e2a6b4446dc1e Mon Sep 17 00:00:00 2001 From: Valentin Petrovych Date: Mon, 17 Nov 2025 14:48:46 +0100 Subject: [PATCH 01/22] feat: migrate to using REST API v2 Remove `app` path parameter. Add `tenant` and `originSlug` parameters. Make host dynamic and apply path `v2`. Move all configuration parameters in Config class and reuse it everywhere. Separate `GoogleAdIdManager` and `LocalStorage` logic from the network client. Rename `Client` to `NetworkClient`. Move hash logic to separate `TypeHasher` class. Separate network interceptors. Refactor SDK code. --- .../co/optable/androidsdkdemo/MainActivity.kt | 9 +- .../java/co/optable/android_sdk/OptableSDK.kt | 200 +++++++++--------- .../co/optable/android_sdk/core/Client.kt | 152 ------------- .../optable/android_sdk/{ => core}/Config.kt | 23 +- .../android_sdk/core/GoogleAdIdManager.kt | 47 ++++ .../optable/android_sdk/core/LocalStorage.kt | 9 +- .../optable/android_sdk/core/NetworkClient.kt | 74 +++++++ .../android_sdk/core/RequestInterceptor.kt | 36 ++++ .../android_sdk/core/ResponseInterceptor.kt | 15 ++ .../co/optable/android_sdk/core/TypeHasher.kt | 56 +++++ .../optable/android_sdk/edge/EdgeService.kt | 23 +- 11 files changed, 364 insertions(+), 280 deletions(-) delete mode 100644 android_sdk/src/main/java/co/optable/android_sdk/core/Client.kt rename android_sdk/src/main/java/co/optable/android_sdk/{ => core}/Config.kt (50%) create mode 100644 android_sdk/src/main/java/co/optable/android_sdk/core/GoogleAdIdManager.kt create mode 100644 android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt create mode 100644 android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt create mode 100644 android_sdk/src/main/java/co/optable/android_sdk/core/ResponseInterceptor.kt create mode 100644 android_sdk/src/main/java/co/optable/android_sdk/core/TypeHasher.kt diff --git a/DemoApp/DemoAppKotlin/app/src/main/java/co/optable/androidsdkdemo/MainActivity.kt b/DemoApp/DemoAppKotlin/app/src/main/java/co/optable/androidsdkdemo/MainActivity.kt index 1b55b9b..fcba9d1 100644 --- a/DemoApp/DemoAppKotlin/app/src/main/java/co/optable/androidsdkdemo/MainActivity.kt +++ b/DemoApp/DemoAppKotlin/app/src/main/java/co/optable/androidsdkdemo/MainActivity.kt @@ -23,7 +23,7 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - OPTABLE = OptableSDK(this, "sandbox.optable.co", "ios-sdk-demo") + OPTABLE = OptableSDK("prebidtest", "js-sdk", this) initUi() } @@ -31,7 +31,12 @@ class MainActivity : AppCompatActivity() { private fun initUi() { val navView: BottomNavigationView = findViewById(R.id.nav_view) val navController = findNavController(R.id.nav_host_fragment) - val appBarConfiguration = AppBarConfiguration(setOf(R.id.navigation_identify, R.id.navigation_gambanner)) + val appBarConfiguration = AppBarConfiguration( + setOf( + R.id.navigation_identify, + R.id.navigation_gambanner, + ) + ) setupActionBarWithNavController(navController, appBarConfiguration) navView.setupWithNavController(navController) } diff --git a/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt b/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt index 8250a11..cb5d276 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt @@ -9,12 +9,10 @@ import android.net.Uri import android.text.TextUtils import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import co.optable.android_sdk.core.Client +import co.optable.android_sdk.core.* import co.optable.android_sdk.edge.EdgeResponse import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import java.security.MessageDigest -import java.util.Locale.getDefault /* * The following typealiases describe the inputs and successful result types of various @@ -29,21 +27,21 @@ typealias OptableIdentifyInput = List /** * Profile API expects user traits: */ -typealias OptableProfileTraits = HashMap +typealias OptableProfileTraits = HashMap /** * Witness API expects event properties: */ -typealias OptableWitnessProperties = HashMap +typealias OptableWitnessProperties = HashMap /** * Identify, Profile, and Witness APIs usually just return {}... Void would be better but that * results in retrofit2 error when parsing response, even when the API responded successfully, * since {} is technically a HashMap: */ -typealias OptableIdentifyResponse = HashMap -typealias OptableProfileResponse = HashMap -typealias OptableWitnessResponse = HashMap +typealias OptableIdentifyResponse = HashMap +typealias OptableProfileResponse = HashMap +typealias OptableWitnessResponse = HashMap /** * Targeting API responds with a key-values dictionary on success: @@ -67,9 +65,30 @@ typealias OptableTargetingResponse = HashMap> * persisted across launches of the app. The state is unique to the app+device, and not globally * unique to the app across devices. */ -class OptableSDK @JvmOverloads constructor(context: Context, host: String, app: String, insecure: Boolean = false, useragent: String? = null, skipAdvertisingIdDetection: Boolean = false) { - val config = Config(host, app, insecure) - val client = Client(config, context, useragent, skipAdvertisingIdDetection) +class OptableSDK @JvmOverloads constructor( + tenant: String, + originSlug: String, + context: Context, + host: String = "na.edge.optable.co", + path: String = "v2", + insecure: Boolean = false, + useragent: String? = null, + skipAdvertisingIdDetection: Boolean = false, +) { + + private val config = Config( + tenant = tenant, + originSlug = originSlug, + userAgent = useragent, + skipAdvertisingIdDetection = skipAdvertisingIdDetection, + host = host, + path = path, + insecure = insecure, + ) + + private val storage = LocalStorage(config, context) + private val adIdManager = GoogleAdIdManager(config, context) + private val client = NetworkClient(config, storage, context) /** * OptableSDK.Status lists all of the possible OptableSDK API result statuses. @@ -93,9 +112,9 @@ class OptableSDK @JvmOverloads constructor(context: Context, host: String, app: fun success(data: T?): Response { return Response(Status.SUCCESS, data, null) } + fun error(err: Error): Response { - return Response(Status.ERROR, null, - err.error + " (trace: " + err.trace + ")") + return Response(Status.ERROR, null, err.error + " (trace: " + err.trace + ")") } } } @@ -111,24 +130,32 @@ class OptableSDK @JvmOverloads constructor(context: Context, host: String, app: */ fun identify(idList: OptableIdentifyInput): LiveData> { val liveData = MutableLiveData>() - val client = this.client GlobalScope.launch { - val response = client.Identify(idList) + val response = client.identify(idList) when (response) { is EdgeResponse.Success -> { liveData.postValue(Response.success(response.body)) } + is EdgeResponse.ApiError -> { liveData.postValue(Response.error(response.body)) } + is EdgeResponse.NetworkError -> { - liveData.postValue(Response.error( - Response.Error("NetworkError", "None"))) + liveData.postValue( + Response.error( + Response.Error("NetworkError", "None") + ) + ) } + is EdgeResponse.UnknownError -> { - liveData.postValue(Response.error( - Response.Error("UnknownError", "None"))) + liveData.postValue( + Response.error( + Response.Error("UnknownError", "None") + ) + ) } } } @@ -148,23 +175,22 @@ class OptableSDK @JvmOverloads constructor(context: Context, host: String, app: * to an empty HashMap on success, and can therefore be ignored. */ @JvmOverloads - fun identify(email: String, gaid: Boolean? = false, ppid: String? = null): - LiveData> - { + fun identify(email: String, gaid: Boolean = false, ppid: String? = null): + LiveData> { var idList: OptableIdentifyInput = listOf() if (!TextUtils.isEmpty(email) && - android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()) - { - idList += Companion.eid(email) + android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() + ) { + idList += TypeHasher.eid(email) } - if (gaid!! && this.client.hasGAID()) { - idList += Companion.gaid(this.client.GAID()!!) + if (gaid && adIdManager.hasId()) { + idList += TypeHasher.gaid(adIdManager.getId()!!) } if ((ppid != null) && (ppid.length > 0)) { - idList += Companion.cid(ppid) + idList += TypeHasher.cid(ppid) } return this.identify(idList) @@ -180,7 +206,7 @@ class OptableSDK @JvmOverloads constructor(context: Context, host: String, app: * as encoded links in newsletter Emails sent by the application developer. */ fun tryIdentifyFromURI(uri: Uri) { - val oeid = Companion.eidFromURI(uri) + val oeid = TypeHasher.eidFromURI(uri) if (oeid.length > 0) { this.identify(listOf(oeid)) @@ -200,24 +226,32 @@ class OptableSDK @JvmOverloads constructor(context: Context, host: String, app: fun profile(traits: OptableProfileTraits): LiveData> { val liveData = MutableLiveData>() - val client = this.client GlobalScope.launch { - val response = client.Profile(traits) + val response = client.profile(traits) when (response) { is EdgeResponse.Success -> { liveData.postValue(Response.success(response.body)) } + is EdgeResponse.ApiError -> { liveData.postValue(Response.error(response.body)) } + is EdgeResponse.NetworkError -> { - liveData.postValue(Response.error( - Response.Error("NetworkError", "None"))) + liveData.postValue( + Response.error( + Response.Error("NetworkError", "None") + ) + ) } + is EdgeResponse.UnknownError -> { - liveData.postValue(Response.error( - Response.Error("UnknownError", "None"))) + liveData.postValue( + Response.error( + Response.Error("UnknownError", "None") + ) + ) } } } @@ -236,25 +270,33 @@ class OptableSDK @JvmOverloads constructor(context: Context, host: String, app: */ fun targeting(): LiveData> { val liveData = MutableLiveData>() - val client = this.client GlobalScope.launch { - val response = client.Targeting() + val response = client.targeting() when (response) { is EdgeResponse.Success -> { - client.TargetingSetCache(response.body) + storage.setTargeting(response.body) liveData.postValue(Response.success(response.body)) } + is EdgeResponse.ApiError -> { liveData.postValue(Response.error(response.body)) } + is EdgeResponse.NetworkError -> { - liveData.postValue(Response.error( - Response.Error("NetworkError", "None"))) + liveData.postValue( + Response.error( + Response.Error("NetworkError", "None") + ) + ) } + is EdgeResponse.UnknownError -> { - liveData.postValue(Response.error( - Response.Error("UnknownError", "None"))) + liveData.postValue( + Response.error( + Response.Error("UnknownError", "None") + ) + ) } } } @@ -263,11 +305,11 @@ class OptableSDK @JvmOverloads constructor(context: Context, host: String, app: } fun targetingFromCache(): OptableTargetingResponse? { - return this.client.TargetingFromCache() + return storage.getTargeting() } fun targetingClearCache() { - this.client.TargetingClearCache() + storage.clearTargeting() } /** @@ -280,27 +322,35 @@ class OptableSDK @JvmOverloads constructor(context: Context, host: String, app: * comparing result.status to OptableSDK.Status.SUCCESS. Note that result.data!! will point * to an empty HashMap on success, and can therefore be ignored. */ - fun witness(event: String, properties: OptableWitnessProperties): - LiveData> { + fun witness(event: String, properties: OptableWitnessProperties): LiveData> { val liveData = MutableLiveData>() val client = this.client GlobalScope.launch { - val response = client.Witness(event, properties) + val response = client.witness(event, properties) when (response) { is EdgeResponse.Success -> { liveData.postValue(Response.success(response.body)) } + is EdgeResponse.ApiError -> { liveData.postValue(Response.error(response.body)) } + is EdgeResponse.NetworkError -> { - liveData.postValue(Response.error( - Response.Error("NetworkError", "None"))) + liveData.postValue( + Response.error( + Response.Error("NetworkError", "None") + ) + ) } + is EdgeResponse.UnknownError -> { - liveData.postValue(Response.error( - Response.Error("UnknownError", "None"))) + liveData.postValue( + Response.error( + Response.Error("UnknownError", "None") + ) + ) } } } @@ -308,52 +358,4 @@ class OptableSDK @JvmOverloads constructor(context: Context, host: String, app: return liveData } - companion object { - /** - * eid(email) is a helper that returns type-prefixed SHA256(downcase(email)) - */ - fun eid(email: String): String { - return "e:" + MessageDigest.getInstance("SHA-256") - .digest(email.lowercase(getDefault()).trim().toByteArray()) - .fold("", { str, it -> str + "%02x".format(it) }) - } - - /** - * gaid(gaid) is a helper that returns the type-prefixed Google Advertising ID - */ - fun gaid(gaid: String): String { - return "g:" + gaid.lowercase(getDefault()).trim() - } - - /** - * cid(ppid) is a helper that returns custom type-prefixed origin-provided PPID - */ - fun cid(ppid: String): String { - return "c:" + ppid.trim() - } - - /** - * eidFromURI(uri) is a helper that returns a type-prefixed ID based on the query string - * oeid=sha256value parameters in the specified uri, if one is found. Otherwise, it returns - * an empty string. - * - * The use for this is when handling incoming deep links which might contain an "oeid" value - * with the SHA256(downcase(email)) of a user, such as encoded links in newsletter Emails - * sent by the application developer. Such hashed Email values can be used in calls to - * identify() - */ - fun eidFromURI(uri: Uri): String { - // We first convert the Uri to a lowercase string then re-parse it so that we are - // not dependent on case-sensitivity of the "oeid" query parameter: - var oeid = Uri.parse(uri.toString().lowercase(getDefault())).getQueryParameter("oeid") - - if ((oeid == null) || (oeid.length != 64) || - (oeid.matches("^[a-f0-9]$".toRegex(RegexOption.IGNORE_CASE)))) - { - return "" - } - - return "e:" + oeid.lowercase(getDefault()) - } - } } \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/Client.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/Client.kt deleted file mode 100644 index cef08e7..0000000 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/Client.kt +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright © 2020 Optable Technologies Inc. All rights reserved. - * See LICENSE for details. - */ -package co.optable.android_sdk.core - -import android.content.Context -import android.text.TextUtils -import android.webkit.WebView -import co.optable.BuildConfig -import co.optable.android_sdk.* -import co.optable.android_sdk.edge.EdgeResponse -import co.optable.android_sdk.edge.EdgeResponseAdapterFactory -import co.optable.android_sdk.edge.EdgeService -import com.google.android.gms.ads.identifier.AdvertisingIdClient -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Response -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory - -class Client(private val config: Config, private val context: Context, private val useragent: String?, private val skipAdvertisingIdDetection: Boolean) { - var gaid: String? = null - var gaidLAT: Boolean? = true - - private val edgeService: EdgeService? - private val storage = LocalStorage(this.config, this.context) - private var userAgent: String? = this.useragent - - private class RequestInterceptor(private val userAgent: String, private val storage: LocalStorage): Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - var oldRequest = chain.request() - - var newURL = oldRequest.url.newBuilder() - .addQueryParameter( - "osdk", "android-${BuildConfig.VERSION_NAME}-${BuildConfig.VERSION_CODE}" - ).build() - var newRequest = oldRequest.newBuilder() - .url(newURL) - .addHeader("User-Agent", userAgent) - - val pass = storage.getPassport() - if (pass != null) { - newRequest = newRequest.addHeader("X-Optable-Visitor", pass) - } - return chain.proceed(newRequest.build()) - } - } - - private class ResponseInterceptor(private val storage: LocalStorage): Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val originalResponse = chain.proceed(chain.request()) - val pass = originalResponse.header("X-Optable-Visitor") - if (pass != null) { - storage.setPassport(pass) - } - return originalResponse.newBuilder().build() - } - } - - init { - if (!this.skipAdvertisingIdDetection) { - this.determineAdvertisingInfo() - } - - if (this.userAgent == null) { - this.userAgent = this.userAgent() - } - - val client = OkHttpClient.Builder() - .addInterceptor(RequestInterceptor(this.userAgent!!, storage)) - .addInterceptor(ResponseInterceptor(storage)) - .build() - - val retrofit = Retrofit.Builder() - .baseUrl(config.edgeBaseURL()) - .addCallAdapterFactory(EdgeResponseAdapterFactory()) - .addConverterFactory(GsonConverterFactory.create()) - .client(client) - .build() - - edgeService = retrofit.create(EdgeService::class.java) - } - - suspend fun Identify(idList: OptableIdentifyInput): - EdgeResponse - { - return edgeService!!.Identify(this.config.app, idList) - } - - suspend fun Profile(traits: OptableProfileTraits): - EdgeResponse - { - val profileBody = HashMap() - profileBody.put("traits", traits) - return edgeService!!.Profile(this.config.app, profileBody) - } - - suspend fun Targeting(): - EdgeResponse - { - return edgeService!!.Targeting(this.config.app) - } - - suspend fun Witness(event: String, properties: OptableWitnessProperties): - EdgeResponse - { - val evtBody = HashMap() - evtBody.put("event", event) - evtBody.put("properties", properties) - return edgeService!!.Witness(this.config.app, evtBody) - } - - fun TargetingSetCache(keyvalues: OptableTargetingResponse) { - storage.setTargeting(keyvalues) - } - - fun TargetingFromCache(): OptableTargetingResponse? { - return storage.getTargeting() - } - - fun TargetingClearCache() { - storage.clearTargeting() - } - - fun hasGAID(): Boolean { - return ((gaid != null) && (gaidLAT == false) && !TextUtils.isEmpty(gaid!!)) - } - - fun GAID(): String? { - return gaid - } - - private fun userAgent(): String { - return WebView(this.context).settings.userAgentString - } - - private fun determineAdvertisingInfo() { - val context = this.context - GlobalScope.launch { - var adInfo: AdvertisingIdClient.Info? = null - try { - adInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) - } catch(e: Exception) {} - - gaid = adInfo?.id - gaidLAT = adInfo?.isLimitAdTrackingEnabled - } - } -} \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/Config.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt similarity index 50% rename from android_sdk/src/main/java/co/optable/android_sdk/Config.kt rename to android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt index 07dc1e3..08c36c1 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/Config.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt @@ -1,19 +1,23 @@ -/* - * Copyright © 2020 Optable Technologies Inc. All rights reserved. - * See LICENSE for details. - */ -package co.optable.android_sdk +package co.optable.android_sdk.core import android.util.Base64 -class Config(val host: String, val app: String, val insecure: Boolean = false) { +internal class Config( + val tenant: String, + val originSlug: String, + val userAgent: String?, + val skipAdvertisingIdDetection: Boolean, + private val host: String, + private val path: String, + private val insecure: Boolean, +) { fun edgeBaseURL(): String { var proto = "https://" - if (this.insecure) { + if (insecure) { proto = "http://" } - return proto + this.host + "/" + return "$proto$host/$path/" } fun passportKey(): String { @@ -25,7 +29,8 @@ class Config(val host: String, val app: String, val insecure: Boolean = false) { } private fun key(kind: String): String { - val sfx = this.host + "/" + this.app + // TODO: New sfx + val sfx = edgeBaseURL() return "OPTABLE_" + kind + "_" + Base64.encodeToString(sfx.toByteArray(), 0) } diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/GoogleAdIdManager.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/GoogleAdIdManager.kt new file mode 100644 index 0000000..e3f46c1 --- /dev/null +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/GoogleAdIdManager.kt @@ -0,0 +1,47 @@ +package co.optable.android_sdk.core + +import android.content.Context +import android.text.TextUtils +import com.google.android.gms.ads.identifier.AdvertisingIdClient +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch + +internal class GoogleAdIdManager( + config: Config, + private val context: Context, +) { + + private var adId: String? = null + private var limitAdTracking: Boolean? = true + + init { + if (!config.skipAdvertisingIdDetection) { + updateAdvertisingId() + } + } + + fun updateAdvertisingId() { + GlobalScope.launch { + var adInfo: AdvertisingIdClient.Info? = null + try { + adInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) + } catch (_: Exception) { + } + + MainScope().launch { + adId = adInfo?.id + limitAdTracking = adInfo?.isLimitAdTrackingEnabled + } + } + } + + fun hasId(): Boolean { + return ((adId != null) && (limitAdTracking == false) && !TextUtils.isEmpty(adId!!)) + } + + fun getId(): String? { + return adId + } + +} \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/LocalStorage.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/LocalStorage.kt index 9f5c1f9..18e67b4 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/LocalStorage.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/LocalStorage.kt @@ -6,13 +6,16 @@ package co.optable.android_sdk.core import android.content.Context import androidx.preference.PreferenceManager -import co.optable.android_sdk.Config import co.optable.android_sdk.OptableTargetingResponse import com.google.gson.Gson import com.google.gson.reflect.TypeToken -internal class LocalStorage(private val config: Config, private val context: Context) { - private val prefs = PreferenceManager.getDefaultSharedPreferences(this.context) +internal class LocalStorage( + private val config: Config, + context: Context, +) { + + private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val passportKey = this.config.passportKey() private val targetingKey = this.config.targetingKey() diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt new file mode 100644 index 0000000..ad4671c --- /dev/null +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt @@ -0,0 +1,74 @@ +/* + * Copyright © 2020 Optable Technologies Inc. All rights reserved. + * See LICENSE for details. + */ +package co.optable.android_sdk.core + +import android.content.Context +import android.webkit.WebView +import co.optable.android_sdk.* +import co.optable.android_sdk.edge.EdgeResponse +import co.optable.android_sdk.edge.EdgeResponseAdapterFactory +import co.optable.android_sdk.edge.EdgeService +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +internal class NetworkClient( + config: Config, + storage: LocalStorage, + private val context: Context, +) { + + private val edgeService: EdgeService + + init { + var userAgent = config.userAgent + if (userAgent == null) { + userAgent = this.userAgentFromWebView() + } + + val client = OkHttpClient.Builder() + .addInterceptor(RequestInterceptor(userAgent, config, storage)) + .addInterceptor(ResponseInterceptor(storage)) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(config.edgeBaseURL()) + .addCallAdapterFactory(EdgeResponseAdapterFactory()) + .addConverterFactory(GsonConverterFactory.create()) + .client(client) + .build() + + edgeService = retrofit.create(EdgeService::class.java) + } + + suspend fun identify(idList: OptableIdentifyInput): EdgeResponse { + return edgeService.identify(idList) + } + + suspend fun profile(traits: OptableProfileTraits): EdgeResponse { + val profileBody = HashMap() + profileBody.put("traits", traits) + return edgeService.profile(profileBody) + } + + suspend fun targeting(): EdgeResponse { + return edgeService.targeting() + } + + suspend fun witness( + event: String, + properties: OptableWitnessProperties, + ): EdgeResponse { + val evtBody = HashMap() + evtBody.put("event", event) + evtBody.put("properties", properties) + return edgeService.witness(evtBody) + } + + private fun userAgentFromWebView(): String { + return WebView(this.context).settings.userAgentString + } + +} \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt new file mode 100644 index 0000000..3e0d414 --- /dev/null +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt @@ -0,0 +1,36 @@ +package co.optable.android_sdk.core + +import co.optable.BuildConfig +import okhttp3.Interceptor +import okhttp3.Response + +internal class RequestInterceptor( + private val userAgent: String?, + private val config: Config, + private val storage: LocalStorage, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + val url = originalRequest.url.newBuilder() + .addQueryParameter("osdk", "android-${BuildConfig.VERSION_NAME}-${BuildConfig.VERSION_CODE}") + .addQueryParameter("t", config.tenant) + .addQueryParameter("o", config.originSlug) + .build() + + val modifiedRequest = originalRequest.newBuilder().url(url) + modifiedRequest.addHeader("Accept", "application/json") + + val userAgent = userAgent + if (userAgent != null) { + modifiedRequest.addHeader("User-Agent", userAgent) + } + + val pass = storage.getPassport() + if (pass != null) { + modifiedRequest.addHeader("X-Optable-Visitor", pass) + } + + return chain.proceed(modifiedRequest.build()) + } +} \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/ResponseInterceptor.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/ResponseInterceptor.kt new file mode 100644 index 0000000..2d03f2e --- /dev/null +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/ResponseInterceptor.kt @@ -0,0 +1,15 @@ +package co.optable.android_sdk.core + +import okhttp3.Interceptor +import okhttp3.Response + +internal class ResponseInterceptor(private val storage: LocalStorage) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalResponse = chain.proceed(chain.request()) + val pass = originalResponse.header("X-Optable-Visitor") + if (pass != null) { + storage.setPassport(pass) + } + return originalResponse.newBuilder().build() + } +} \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/TypeHasher.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/TypeHasher.kt new file mode 100644 index 0000000..3720ee0 --- /dev/null +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/TypeHasher.kt @@ -0,0 +1,56 @@ +package co.optable.android_sdk.core + +import android.net.Uri +import java.security.MessageDigest +import java.util.Locale.getDefault + +object TypeHasher { + + /** + * eid(email) is a helper that returns type-prefixed SHA256(downcase(email)) + */ + fun eid(email: String): String { + return "e:" + MessageDigest.getInstance("SHA-256") + .digest(email.lowercase(getDefault()).trim().toByteArray()) + .fold("", { str, it -> str + "%02x".format(it) }) + } + + /** + * gaid(gaid) is a helper that returns the type-prefixed Google Advertising ID + */ + fun gaid(gaid: String): String { + return "g:" + gaid.lowercase(getDefault()).trim() + } + + /** + * cid(ppid) is a helper that returns custom type-prefixed origin-provided PPID + */ + fun cid(ppid: String): String { + return "c:" + ppid.trim() + } + + /** + * eidFromURI(uri) is a helper that returns a type-prefixed ID based on the query string + * oeid=sha256value parameters in the specified uri, if one is found. Otherwise, it returns + * an empty string. + * + * The use for this is when handling incoming deep links which might contain an "oeid" value + * with the SHA256(downcase(email)) of a user, such as encoded links in newsletter Emails + * sent by the application developer. Such hashed Email values can be used in calls to + * identify() + */ + fun eidFromURI(uri: Uri): String { + // We first convert the Uri to a lowercase string then re-parse it so that we are + // not dependent on case-sensitivity of the "oeid" query parameter: + var oeid = Uri.parse(uri.toString().lowercase(getDefault())).getQueryParameter("oeid") + + if ((oeid == null) || (oeid.length != 64) || + (oeid.matches("^[a-f0-9]$".toRegex(RegexOption.IGNORE_CASE))) + ) { + return "" + } + + return "e:" + oeid.lowercase(getDefault()) + } + +} \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/edge/EdgeService.kt b/android_sdk/src/main/java/co/optable/android_sdk/edge/EdgeService.kt index 0106f5f..97c7602 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/edge/EdgeService.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/edge/EdgeService.kt @@ -8,26 +8,19 @@ import co.optable.android_sdk.* import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST -import retrofit2.http.Path interface EdgeService { - @POST("/{app}/identify") - suspend fun Identify(@Path("app") app: String, @Body idList: OptableIdentifyInput): - EdgeResponse + @POST("/identify") + suspend fun identify(@Body idList: OptableIdentifyInput): EdgeResponse - @POST("/{app}/profile") - suspend fun Profile(@Path("app") app: String, - @Body profileBody: HashMap): - EdgeResponse + @POST("/profile") + suspend fun profile(@Body profileBody: HashMap): EdgeResponse - @GET("/{app}/targeting") - suspend fun Targeting(@Path("app") app: String): - EdgeResponse + @GET("/targeting") + suspend fun targeting(): EdgeResponse - @POST("/{app}/witness") - suspend fun Witness(@Path("app") app: String, - @Body witnessBody: HashMap): - EdgeResponse + @POST("/witness") + suspend fun witness(@Body witnessBody: HashMap): EdgeResponse } \ No newline at end of file From 8d6e1de5b38a5c33d5da338af73229375cfb77a0 Mon Sep 17 00:00:00 2001 From: Valentin Petrovych Date: Mon, 17 Nov 2025 15:07:50 +0100 Subject: [PATCH 02/22] fix: unit tests --- .../optable/android_sdk/OptableSDKUnitTest.kt | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/android_sdk/src/test/java/co/optable/android_sdk/OptableSDKUnitTest.kt b/android_sdk/src/test/java/co/optable/android_sdk/OptableSDKUnitTest.kt index 6ebe448..456b62d 100644 --- a/android_sdk/src/test/java/co/optable/android_sdk/OptableSDKUnitTest.kt +++ b/android_sdk/src/test/java/co/optable/android_sdk/OptableSDKUnitTest.kt @@ -5,6 +5,7 @@ package co.optable.android_sdk import android.net.Uri +import co.optable.android_sdk.core.TypeHasher import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Test @@ -21,10 +22,10 @@ class OptableSDKUnitTest { fun eid_isCorrect() { val expected = "e:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" - assertEquals(expected, OptableSDK.eid("123")) - assertEquals(expected, OptableSDK.eid(" 123")) - assertEquals(expected, OptableSDK.eid("123 ")) - assertEquals(expected, OptableSDK.eid(" 123 ")) + assertEquals(expected, TypeHasher.eid("123")) + assertEquals(expected, TypeHasher.eid(" 123")) + assertEquals(expected, TypeHasher.eid("123 ")) + assertEquals(expected, TypeHasher.eid(" 123 ")) } @Test @@ -33,39 +34,39 @@ class OptableSDKUnitTest { val var2 = "test@foobarbaz.com" val var3 = "TEST@FOOBARBAZ.COM" val var4 = "TeSt@fOObARbAZ.cOm" - val eid = OptableSDK.eid(var1) + val eid = TypeHasher.eid(var1) - assertEquals(eid, OptableSDK.eid(var2)) - assertEquals(eid, OptableSDK.eid(var3)) - assertEquals(eid, OptableSDK.eid(var4)) + assertEquals(eid, TypeHasher.eid(var2)) + assertEquals(eid, TypeHasher.eid(var3)) + assertEquals(eid, TypeHasher.eid(var4)) } @Test fun gaid_isCorrectAndIgnoresCase() { val expected = "g:38400000-8cf0-11bd-b23e-10b96e40000d" - assertEquals(expected, OptableSDK.gaid("38400000-8cf0-11bd-b23e-10b96e40000d")) - assertEquals(expected, OptableSDK.gaid(" 38400000-8cf0-11bd-b23e-10b96e40000d")) - assertEquals(expected, OptableSDK.gaid("38400000-8cf0-11bd-b23e-10b96e40000d ")) - assertEquals(expected, OptableSDK.gaid(" 38400000-8cf0-11bd-b23e-10b96e40000d ")) - assertEquals(expected, OptableSDK.gaid("38400000-8CF0-11BD-B23E-10B96E40000D")) + assertEquals(expected, TypeHasher.gaid("38400000-8cf0-11bd-b23e-10b96e40000d")) + assertEquals(expected, TypeHasher.gaid(" 38400000-8cf0-11bd-b23e-10b96e40000d")) + assertEquals(expected, TypeHasher.gaid("38400000-8cf0-11bd-b23e-10b96e40000d ")) + assertEquals(expected, TypeHasher.gaid(" 38400000-8cf0-11bd-b23e-10b96e40000d ")) + assertEquals(expected, TypeHasher.gaid("38400000-8CF0-11BD-B23E-10B96E40000D")) } @Test fun cid_isCorrect() { val expected = "c:FooBarBAZ-01234#98765.!!!" - assertEquals(expected, OptableSDK.cid("FooBarBAZ-01234#98765.!!!")) - assertEquals(expected, OptableSDK.cid(" FooBarBAZ-01234#98765.!!!")) - assertEquals(expected, OptableSDK.cid("FooBarBAZ-01234#98765.!!! ")) - assertEquals(expected, OptableSDK.cid(" FooBarBAZ-01234#98765.!!! ")) + assertEquals(expected, TypeHasher.cid("FooBarBAZ-01234#98765.!!!")) + assertEquals(expected, TypeHasher.cid(" FooBarBAZ-01234#98765.!!!")) + assertEquals(expected, TypeHasher.cid("FooBarBAZ-01234#98765.!!! ")) + assertEquals(expected, TypeHasher.cid(" FooBarBAZ-01234#98765.!!! ")) } @Test fun cid_isCaseSensitive() { val unexpected = "c:FooBarBAZ-01234#98765.!!!" - assertNotEquals(unexpected, OptableSDK.cid("foobarBAZ-01234#98765.!!!")) + assertNotEquals(unexpected, TypeHasher.cid("foobarBAZ-01234#98765.!!!")) } @Test @@ -73,7 +74,7 @@ class OptableSDKUnitTest { val url = "http://some.domain.com/some/path?some=query&something=else&oeid=a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3&foo=bar&baz" val expected = "e:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" - assertEquals(expected, OptableSDK.eidFromURI(Uri.parse(url))) + assertEquals(expected, TypeHasher.eidFromURI(Uri.parse(url))) } @Test @@ -81,7 +82,7 @@ class OptableSDKUnitTest { val url = "http://some.domain.com/some/path?some=query&something=else" val expected = "" - assertEquals(expected, OptableSDK.eidFromURI(Uri.parse(url))) + assertEquals(expected, TypeHasher.eidFromURI(Uri.parse(url))) } @Test @@ -89,7 +90,7 @@ class OptableSDKUnitTest { val url = "http://some.domain.com/some/path" val expected = "" - assertEquals(expected, OptableSDK.eidFromURI(Uri.parse(url))) + assertEquals(expected, TypeHasher.eidFromURI(Uri.parse(url))) } @Test @@ -97,7 +98,7 @@ class OptableSDKUnitTest { val url = "" val expected = "" - assertEquals(expected, OptableSDK.eidFromURI(Uri.parse(url))) + assertEquals(expected, TypeHasher.eidFromURI(Uri.parse(url))) } @Test @@ -105,7 +106,7 @@ class OptableSDKUnitTest { val url = "http://some.domain.com/some/path?some=query&something=else&oeid=AAAAAAAa665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3&foo=bar&baz" val expected = "" - assertEquals(expected, OptableSDK.eidFromURI(Uri.parse(url))) + assertEquals(expected, TypeHasher.eidFromURI(Uri.parse(url))) } @Test @@ -113,6 +114,6 @@ class OptableSDKUnitTest { val url = "http://some.domain.com/some/path?some=query&something=else&oEId=A665A45920422F9D417E4867EFDC4FB8A04A1F3FFF1FA07E998E86f7f7A27AE3&foo=bar&baz" val expected = "e:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" - assertEquals(expected, OptableSDK.eidFromURI(Uri.parse(url))) + assertEquals(expected, TypeHasher.eidFromURI(Uri.parse(url))) } } \ No newline at end of file From 7efd435c0a8363f8bb693a32bb05fa492dbf4ea6 Mon Sep 17 00:00:00 2001 From: Valentin Petrovych Date: Mon, 17 Nov 2025 15:45:50 +0100 Subject: [PATCH 03/22] feat: new unique suffix --- .../src/main/java/co/optable/android_sdk/core/Config.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt index 08c36c1..997b5e5 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt @@ -29,8 +29,7 @@ internal class Config( } private fun key(kind: String): String { - // TODO: New sfx - val sfx = edgeBaseURL() + val sfx = host + "/" + tenant + "/" + originSlug return "OPTABLE_" + kind + "_" + Base64.encodeToString(sfx.toByteArray(), 0) } From dc6b91f69863fdb51db83795a63671a1928109be Mon Sep 17 00:00:00 2001 From: Valentin Petrovych Date: Tue, 18 Nov 2025 15:08:22 +0100 Subject: [PATCH 04/22] feat: use local SDK sources by default --- .gitignore | 1 + DemoApp/DemoAppJava/app/build.gradle | 6 +++++- .../src/main/java/co/optable/demoappjava/MainActivity.java | 2 +- DemoApp/DemoAppJava/gradle.properties | 3 ++- DemoApp/DemoAppJava/settings.gradle | 5 +++++ DemoApp/DemoAppKotlin/app/build.gradle | 6 +++++- DemoApp/DemoAppKotlin/gradle.properties | 3 ++- DemoApp/DemoAppKotlin/settings.gradle | 5 +++++ 8 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 71f9913..45b2fa1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ g*.iml .gradle .idea .kotlin +libs local.properties diff --git a/DemoApp/DemoAppJava/app/build.gradle b/DemoApp/DemoAppJava/app/build.gradle index 44e2c9b..bbadf30 100644 --- a/DemoApp/DemoAppJava/app/build.gradle +++ b/DemoApp/DemoAppJava/app/build.gradle @@ -41,7 +41,11 @@ android { dependencies { // Optable SDK - implementation "com.github.Optable:optable-android-sdk:" + versioning.getVersionName(false) + if (useReleaseSdk == "true") { + implementation "com.github.Optable:optable-android-sdk:" + versioning.getVersionName(false) + } else { + implementation "co.optable:local-sdk" + } // Google Mobile Ads implementation 'com.google.android.gms:play-services-ads:24.6.0' diff --git a/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java b/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java index ef7ce31..bc949d5 100644 --- a/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java +++ b/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java @@ -18,7 +18,7 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); - MainActivity.OPTABLE = new OptableSDK(this.getApplicationContext(), "sandbox.optable.co", "ios-sdk-demo"); + MainActivity.OPTABLE = new OptableSDK("prebidtest", "js-sdk", this); initUi(); } diff --git a/DemoApp/DemoAppJava/gradle.properties b/DemoApp/DemoAppJava/gradle.properties index c52ac9b..d71fa93 100644 --- a/DemoApp/DemoAppJava/gradle.properties +++ b/DemoApp/DemoAppJava/gradle.properties @@ -16,4 +16,5 @@ org.gradle.jvmargs=-Xmx2048m # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true \ No newline at end of file +android.enableJetifier=true +useReleaseSdk=false \ No newline at end of file diff --git a/DemoApp/DemoAppJava/settings.gradle b/DemoApp/DemoAppJava/settings.gradle index f864b0b..f9963f4 100644 --- a/DemoApp/DemoAppJava/settings.gradle +++ b/DemoApp/DemoAppJava/settings.gradle @@ -16,4 +16,9 @@ dependencyResolutionManagement { } include ':app' +includeBuild("../..") { + dependencySubstitution { + substitute(module("co.optable:local-sdk")).using(project(":android_sdk")) + } +} rootProject.name = "DemoJava" \ No newline at end of file diff --git a/DemoApp/DemoAppKotlin/app/build.gradle b/DemoApp/DemoAppKotlin/app/build.gradle index 810d749..4577e85 100644 --- a/DemoApp/DemoAppKotlin/app/build.gradle +++ b/DemoApp/DemoAppKotlin/app/build.gradle @@ -41,7 +41,11 @@ android { dependencies { // Optable SDK - implementation "com.github.Optable:optable-android-sdk:" + versioning.getVersionName(false) + if (useReleaseSdk == "true") { + implementation "com.github.Optable:optable-android-sdk:" + versioning.getVersionName(false) + } else { + implementation "co.optable:local-sdk" + } // Google Mobile Ads implementation 'com.google.android.gms:play-services-ads:24.6.0' diff --git a/DemoApp/DemoAppKotlin/gradle.properties b/DemoApp/DemoAppKotlin/gradle.properties index 4d15d01..6ca214b 100644 --- a/DemoApp/DemoAppKotlin/gradle.properties +++ b/DemoApp/DemoAppKotlin/gradle.properties @@ -18,4 +18,5 @@ android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +useReleaseSdk=false \ No newline at end of file diff --git a/DemoApp/DemoAppKotlin/settings.gradle b/DemoApp/DemoAppKotlin/settings.gradle index 913b5de..268b821 100644 --- a/DemoApp/DemoAppKotlin/settings.gradle +++ b/DemoApp/DemoAppKotlin/settings.gradle @@ -16,4 +16,9 @@ dependencyResolutionManagement { } include ':app' +includeBuild("../..") { + dependencySubstitution { + substitute(module("co.optable:local-sdk")).using(project(":android_sdk")) + } +} rootProject.name = "DemoKotlin" \ No newline at end of file From 71fe63ad139e93457e596b081fd2751eefad2cc4 Mon Sep 17 00:00:00 2001 From: Valentin Petrovych Date: Fri, 21 Nov 2025 09:52:07 +0100 Subject: [PATCH 05/22] feat: api key and testing Support secret api keys. Fetch user agent only once. Write unit tests with the mock web server. --- android_sdk/build.gradle | 7 +- .../java/co/optable/android_sdk/OptableSDK.kt | 14 +- .../co/optable/android_sdk/core/Config.kt | 4 +- .../optable/android_sdk/core/NetworkClient.kt | 19 +-- .../android_sdk/core/RequestInterceptor.kt | 9 +- .../android_sdk/core/ResponseInterceptor.kt | 6 +- .../android_sdk/core/UserAgentHolder.kt | 24 +++ .../optable/android_sdk/OptableSDKUnitTest.kt | 2 + .../android_sdk/core/MockWebServerTest.kt | 140 ++++++++++++++++++ .../core/ResponseInterceptorTest.kt | 74 +++++++++ 10 files changed, 274 insertions(+), 25 deletions(-) create mode 100644 android_sdk/src/main/java/co/optable/android_sdk/core/UserAgentHolder.kt create mode 100644 android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt create mode 100644 android_sdk/src/test/java/co/optable/android_sdk/core/ResponseInterceptorTest.kt diff --git a/android_sdk/build.gradle b/android_sdk/build.gradle index 32980ff..6fb7d4f 100644 --- a/android_sdk/build.gradle +++ b/android_sdk/build.gradle @@ -5,12 +5,12 @@ plugins { } android { - compileSdk 34 + compileSdk 35 defaultConfig { namespace "co.optable" minSdkVersion 23 - targetSdkVersion 34 + targetSdkVersion 35 // Here we get version code and version name from latest git release tags: versionCode versioning.getVersionCode() @@ -65,6 +65,9 @@ dependencies { // Unit tests testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:5.12.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") testImplementation("org.robolectric:robolectric:4.16") // UI tests diff --git a/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt b/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt index cb5d276..1619f2d 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt @@ -72,14 +72,15 @@ class OptableSDK @JvmOverloads constructor( host: String = "na.edge.optable.co", path: String = "v2", insecure: Boolean = false, - useragent: String? = null, + apiKey: String? = null, + userAgent: String? = null, skipAdvertisingIdDetection: Boolean = false, ) { private val config = Config( tenant = tenant, originSlug = originSlug, - userAgent = useragent, + apiKey = apiKey, skipAdvertisingIdDetection = skipAdvertisingIdDetection, host = host, path = path, @@ -88,7 +89,7 @@ class OptableSDK @JvmOverloads constructor( private val storage = LocalStorage(config, context) private val adIdManager = GoogleAdIdManager(config, context) - private val client = NetworkClient(config, storage, context) + private val client = createNetworkClient(userAgent, context) /** * OptableSDK.Status lists all of the possible OptableSDK API result statuses. @@ -358,4 +359,11 @@ class OptableSDK @JvmOverloads constructor( return liveData } + private fun createNetworkClient(userAgent: String?, context: Context): NetworkClient { + val userAgentHolder = UserAgentHolder(userAgent, context) + val requestInterceptor = RequestInterceptor(config, storage, userAgentHolder) + val responseInterceptor = ResponseInterceptor(storage) + return NetworkClient(config, requestInterceptor, responseInterceptor) + } + } \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt index 997b5e5..17be0e7 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt @@ -5,7 +5,7 @@ import android.util.Base64 internal class Config( val tenant: String, val originSlug: String, - val userAgent: String?, + val apiKey: String?, val skipAdvertisingIdDetection: Boolean, private val host: String, private val path: String, @@ -29,7 +29,7 @@ internal class Config( } private fun key(kind: String): String { - val sfx = host + "/" + tenant + "/" + originSlug + val sfx = "$host/$tenant/$originSlug" return "OPTABLE_" + kind + "_" + Base64.encodeToString(sfx.toByteArray(), 0) } diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt index ad4671c..48d780b 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt @@ -4,8 +4,6 @@ */ package co.optable.android_sdk.core -import android.content.Context -import android.webkit.WebView import co.optable.android_sdk.* import co.optable.android_sdk.edge.EdgeResponse import co.optable.android_sdk.edge.EdgeResponseAdapterFactory @@ -16,21 +14,16 @@ import retrofit2.converter.gson.GsonConverterFactory internal class NetworkClient( config: Config, - storage: LocalStorage, - private val context: Context, + requestInterceptor: RequestInterceptor, + responseInterceptor: ResponseInterceptor, ) { private val edgeService: EdgeService init { - var userAgent = config.userAgent - if (userAgent == null) { - userAgent = this.userAgentFromWebView() - } - val client = OkHttpClient.Builder() - .addInterceptor(RequestInterceptor(userAgent, config, storage)) - .addInterceptor(ResponseInterceptor(storage)) + .addInterceptor(requestInterceptor) + .addInterceptor(responseInterceptor) .build() val retrofit = Retrofit.Builder() @@ -67,8 +60,4 @@ internal class NetworkClient( return edgeService.witness(evtBody) } - private fun userAgentFromWebView(): String { - return WebView(this.context).settings.userAgentString - } - } \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt index 3e0d414..c52e3bc 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt @@ -5,9 +5,9 @@ import okhttp3.Interceptor import okhttp3.Response internal class RequestInterceptor( - private val userAgent: String?, private val config: Config, private val storage: LocalStorage, + private val userAgentHolder: UserAgentHolder, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() @@ -21,7 +21,12 @@ internal class RequestInterceptor( val modifiedRequest = originalRequest.newBuilder().url(url) modifiedRequest.addHeader("Accept", "application/json") - val userAgent = userAgent + val apiKey = config.apiKey + if (apiKey != null) { + modifiedRequest.addHeader("Authorization", "Bearer $apiKey") + } + + val userAgent = userAgentHolder.getUserAgent() if (userAgent != null) { modifiedRequest.addHeader("User-Agent", userAgent) } diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/ResponseInterceptor.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/ResponseInterceptor.kt index 2d03f2e..6e10247 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/ResponseInterceptor.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/ResponseInterceptor.kt @@ -3,7 +3,10 @@ package co.optable.android_sdk.core import okhttp3.Interceptor import okhttp3.Response -internal class ResponseInterceptor(private val storage: LocalStorage) : Interceptor { +internal class ResponseInterceptor( + private val storage: LocalStorage, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { val originalResponse = chain.proceed(chain.request()) val pass = originalResponse.header("X-Optable-Visitor") @@ -12,4 +15,5 @@ internal class ResponseInterceptor(private val storage: LocalStorage) : Intercep } return originalResponse.newBuilder().build() } + } \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/UserAgentHolder.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/UserAgentHolder.kt new file mode 100644 index 0000000..bdfc596 --- /dev/null +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/UserAgentHolder.kt @@ -0,0 +1,24 @@ +package co.optable.android_sdk.core + +import android.content.Context +import android.webkit.WebView + +internal class UserAgentHolder( + customUserAgent: String? = null, + context: Context, +) { + + private val cachedUserAgent: String? = customUserAgent ?: userAgentFromWebView(context) + + fun getUserAgent() = cachedUserAgent + + + private fun userAgentFromWebView(context: Context): String? { + return try { + WebView(context).settings.userAgentString + } catch (_: Exception) { + null + } + } + +} \ No newline at end of file diff --git a/android_sdk/src/test/java/co/optable/android_sdk/OptableSDKUnitTest.kt b/android_sdk/src/test/java/co/optable/android_sdk/OptableSDKUnitTest.kt index 456b62d..d5ded14 100644 --- a/android_sdk/src/test/java/co/optable/android_sdk/OptableSDKUnitTest.kt +++ b/android_sdk/src/test/java/co/optable/android_sdk/OptableSDKUnitTest.kt @@ -11,11 +11,13 @@ import org.junit.Assert.assertNotEquals import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config /** * OptableSDK unit tests */ @RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) class OptableSDKUnitTest { @Test diff --git a/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt b/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt new file mode 100644 index 0000000..2fcbe0a --- /dev/null +++ b/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt @@ -0,0 +1,140 @@ +package co.optable.android_sdk.core + +import co.optable.BuildConfig +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class MockWebServerTest { + + @Mock + private lateinit var config: Config + + @Mock + private lateinit var storage: LocalStorage + + @Mock + private lateinit var userAgentHolder: UserAgentHolder + + private lateinit var mockWebServer: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var webServerUrl: HttpUrl + private lateinit var mocks: AutoCloseable + + @Before + fun setUp() { + mocks = MockitoAnnotations.openMocks(this) + mockWebServer = MockWebServer() + mockWebServer.start() + mockWebServer.enqueue(MockResponse()) + webServerUrl = mockWebServer.url("/") + + } + + @After + fun tearDown() { + mockWebServer.shutdown() + mocks.close() + } + + @Test + fun `only required fields`() { + whenever(config.tenant).thenReturn("tenant") + whenever(config.originSlug).thenReturn("originSlug") + + val request = makeRequest().request + + assertNull(request.headers["Authorization"]) + assertNull(request.headers["X-Optable-Visitor"]) + assertNull(request.headers["User-Agent"]) + + assertEquals("application/json", request.headers["Accept"]) + assertEquals( + "android-" + BuildConfig.VERSION_NAME + "-" + BuildConfig.VERSION_CODE, + request.url.queryParameter("osdk") + ) + assertEquals("tenant", request.url.queryParameter("t")) + assertEquals("originSlug", request.url.queryParameter("o")) + } + + @Test + fun `optional header, api key`() { + whenever(config.apiKey).thenReturn("apiKey") + + val request = makeRequest().request + + assertEquals("Bearer apiKey", request.headers["Authorization"]) + } + + @Test + fun `optional header, passport`() { + whenever(storage.getPassport()).thenReturn("passport") + + val request = makeRequest().request + + assertEquals("passport", request.headers["X-Optable-Visitor"]) + } + + @Test + fun `complete request`() { + whenever(config.tenant).thenReturn("tenant") + whenever(config.originSlug).thenReturn("originSlug") + whenever(config.apiKey).thenReturn("apiKey") + whenever(storage.getPassport()).thenReturn("passport") + whenever(userAgentHolder.getUserAgent()).thenReturn("userAgent") + + val request = makeRequest().request + + assertEquals("application/json", request.headers["Accept"]) + assertEquals("Bearer apiKey", request.headers["Authorization"]) + verify(config, times(1)).apiKey + assertEquals("passport", request.headers["X-Optable-Visitor"]) + verify(storage, times(1)).getPassport() + assertEquals("userAgent", request.headers["User-Agent"]) + verify(userAgentHolder, times(1)).getUserAgent() + + assertEquals( + "android-" + BuildConfig.VERSION_NAME + "-" + BuildConfig.VERSION_CODE, + request.url.queryParameter("osdk") + ) + assertEquals("tenant", request.url.queryParameter("t")) + verify(config, times(1)).tenant + assertEquals("originSlug", request.url.queryParameter("o")) + verify(config, times(1)).originSlug + } + + @Test + fun `full url test`() { + whenever(config.tenant).thenReturn("tenant") + whenever(config.originSlug).thenReturn("originSlug") + + val request = makeRequest().request + val expectedQueryParams = + "?osdk=android-${BuildConfig.VERSION_NAME}-${BuildConfig.VERSION_CODE}&t=tenant&o=originSlug" + assertEquals(webServerUrl.toString() + expectedQueryParams, request.url.toString()) + } + + private fun makeRequest(): Response { + val interceptor = RequestInterceptor(config, storage, userAgentHolder) + client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + val request = Request.Builder().url(webServerUrl).build() + return client.newCall(request).execute() + } + +} diff --git a/android_sdk/src/test/java/co/optable/android_sdk/core/ResponseInterceptorTest.kt b/android_sdk/src/test/java/co/optable/android_sdk/core/ResponseInterceptorTest.kt new file mode 100644 index 0000000..c76ddc1 --- /dev/null +++ b/android_sdk/src/test/java/co/optable/android_sdk/core/ResponseInterceptorTest.kt @@ -0,0 +1,74 @@ +package co.optable.android_sdk.core + +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +class ResponseInterceptorTest { + + @Mock + private lateinit var storage: LocalStorage + + private lateinit var mockWebServer: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var webServerUrl: HttpUrl + private lateinit var mocks: AutoCloseable + + @Before + fun setUp() { + mocks = MockitoAnnotations.openMocks(this) + mockWebServer = MockWebServer() + mockWebServer.start() + webServerUrl = mockWebServer.url("/") + } + + @After + fun tearDown() { + mockWebServer.shutdown() + mocks.close() + } + + + @Test + fun `interceptor saves passport to storage`() { + makeRequest("passport") + + verify(storage).setPassport("passport") + } + + @Test + fun `interceptor ignores empty header`() { + makeRequest(null) + + verify(storage, never()).setPassport(any()) + } + + private fun makeRequest(passport: String?): Response { + val response = MockResponse() + response.setResponseCode(200) + if (passport != null) { + response.setHeader("X-Optable-Visitor", passport) + } + mockWebServer.enqueue(response) + + val responseInterceptor = ResponseInterceptor(storage) + client = OkHttpClient.Builder() + .addInterceptor(responseInterceptor) + .build() + + val request = Request.Builder().url(webServerUrl).build() + return client.newCall(request).execute() + } + +} \ No newline at end of file From ad8359b1d3e918653331cfb55d20bda8c02ea5ee Mon Sep 17 00:00:00 2001 From: Valentin Petrovych Date: Mon, 15 Dec 2025 13:47:12 +0100 Subject: [PATCH 06/22] feat: introduce `OptableConfig` --- .../co/optable/demoappjava/MainActivity.java | 4 +- .../co/optable/androidsdkdemo/MainActivity.kt | 4 +- .../co/optable/android_sdk/OptableConfig.kt | 42 +++++++++++++++++++ .../java/co/optable/android_sdk/OptableSDK.kt | 31 +++----------- .../co/optable/android_sdk/core/Config.kt | 36 ---------------- .../android_sdk/core/GoogleAdIdManager.kt | 7 ++-- .../optable/android_sdk/core/LocalStorage.kt | 11 +++-- .../optable/android_sdk/core/NetworkClient.kt | 4 +- .../android_sdk/core/RequestInterceptor.kt | 3 +- .../co/optable/android_sdk/core/TypeHasher.kt | 15 +++++++ .../android_sdk/core/UserAgentHolder.kt | 6 +-- .../android_sdk/core/MockWebServerTest.kt | 2 +- 12 files changed, 85 insertions(+), 80 deletions(-) create mode 100644 android_sdk/src/main/java/co/optable/android_sdk/OptableConfig.kt delete mode 100644 android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt diff --git a/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java b/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java index bc949d5..90235cd 100644 --- a/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java +++ b/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java @@ -6,6 +6,7 @@ import androidx.navigation.Navigation; import androidx.navigation.ui.AppBarConfiguration; import androidx.navigation.ui.NavigationUI; +import co.optable.android_sdk.OptableConfig; import co.optable.android_sdk.OptableSDK; import com.google.android.material.bottomnavigation.BottomNavigationView; @@ -18,7 +19,8 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); - MainActivity.OPTABLE = new OptableSDK("prebidtest", "js-sdk", this); + OptableConfig config = new OptableConfig(this, "prebidtest", "js-sdk"); + MainActivity.OPTABLE = new OptableSDK(config); initUi(); } diff --git a/DemoApp/DemoAppKotlin/app/src/main/java/co/optable/androidsdkdemo/MainActivity.kt b/DemoApp/DemoAppKotlin/app/src/main/java/co/optable/androidsdkdemo/MainActivity.kt index fcba9d1..e0c7f85 100644 --- a/DemoApp/DemoAppKotlin/app/src/main/java/co/optable/androidsdkdemo/MainActivity.kt +++ b/DemoApp/DemoAppKotlin/app/src/main/java/co/optable/androidsdkdemo/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.navigation.findNavController import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupWithNavController +import co.optable.android_sdk.OptableConfig import co.optable.android_sdk.OptableSDK import com.google.android.material.bottomnavigation.BottomNavigationView @@ -23,7 +24,8 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - OPTABLE = OptableSDK("prebidtest", "js-sdk", this) + val config = OptableConfig(this, "prebidtest", "js-sdk") + OPTABLE = OptableSDK(config) initUi() } diff --git a/android_sdk/src/main/java/co/optable/android_sdk/OptableConfig.kt b/android_sdk/src/main/java/co/optable/android_sdk/OptableConfig.kt new file mode 100644 index 0000000..287ead5 --- /dev/null +++ b/android_sdk/src/main/java/co/optable/android_sdk/OptableConfig.kt @@ -0,0 +1,42 @@ +package co.optable.android_sdk + +import android.content.Context + + +/** + * Configuration class for Optable integration. + * + * @constructor Creates an instance of OptableConfig with specified or default parameters. + * + * @param tenant The tenant name associated with the configuration. E.g. `acmeco.optable.co` => `acmeco`. + * @param originSlug The DCN's Source Slug. E.g. `acmeco-sdk`. + * @param host The hostname of the Optable endpoint. Default value is "na.edge.optable.co". + * @param path The API path to be appended to the host. Default value is "v2". + * @param insecure Boolean flag that determines if insecure HTTP should be used instead of HTTPS. Default is false. + * @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. + */ +class OptableConfig @JvmOverloads constructor( + providedContext: Context, + internal val tenant: String, + internal val originSlug: String, + internal val host: String = "na.edge.optable.co", + internal val path: String = "v2", + internal val insecure: Boolean = false, + internal val apiKey: String? = null, + internal val customUserAgent: String? = null, + internal val skipAdvertisingIdDetection: Boolean = false, +) { + + internal val context = providedContext.applicationContext + + internal fun getBaseUrl(): String { + var proto = "https://" + if (insecure) { + proto = "http://" + } + return "$proto$host/$path/" + } + +} \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt b/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt index 1619f2d..3f665e9 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/OptableSDK.kt @@ -4,7 +4,6 @@ */ package co.optable.android_sdk -import android.content.Context import android.net.Uri import android.text.TextUtils import androidx.lifecycle.LiveData @@ -66,30 +65,12 @@ typealias OptableTargetingResponse = HashMap> * unique to the app across devices. */ class OptableSDK @JvmOverloads constructor( - tenant: String, - originSlug: String, - context: Context, - host: String = "na.edge.optable.co", - path: String = "v2", - insecure: Boolean = false, - apiKey: String? = null, - userAgent: String? = null, - skipAdvertisingIdDetection: Boolean = false, + private val config: OptableConfig, ) { - private val config = Config( - tenant = tenant, - originSlug = originSlug, - apiKey = apiKey, - skipAdvertisingIdDetection = skipAdvertisingIdDetection, - host = host, - path = path, - insecure = insecure, - ) - - private val storage = LocalStorage(config, context) - private val adIdManager = GoogleAdIdManager(config, context) - private val client = createNetworkClient(userAgent, context) + private val storage = LocalStorage(config) + private val adIdManager = GoogleAdIdManager(config) + private val client = createNetworkClient() /** * OptableSDK.Status lists all of the possible OptableSDK API result statuses. @@ -359,8 +340,8 @@ class OptableSDK @JvmOverloads constructor( return liveData } - private fun createNetworkClient(userAgent: String?, context: Context): NetworkClient { - val userAgentHolder = UserAgentHolder(userAgent, context) + private fun createNetworkClient(): NetworkClient { + val userAgentHolder = UserAgentHolder(config) val requestInterceptor = RequestInterceptor(config, storage, userAgentHolder) val responseInterceptor = ResponseInterceptor(storage) return NetworkClient(config, requestInterceptor, responseInterceptor) diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt deleted file mode 100644 index 17be0e7..0000000 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/Config.kt +++ /dev/null @@ -1,36 +0,0 @@ -package co.optable.android_sdk.core - -import android.util.Base64 - -internal class Config( - val tenant: String, - val originSlug: String, - val apiKey: String?, - val skipAdvertisingIdDetection: Boolean, - private val host: String, - private val path: String, - private val insecure: Boolean, -) { - - fun edgeBaseURL(): String { - var proto = "https://" - if (insecure) { - proto = "http://" - } - return "$proto$host/$path/" - } - - fun passportKey(): String { - return key("PASS") - } - - fun targetingKey(): String { - return key("TGT") - } - - private fun key(kind: String): String { - val sfx = "$host/$tenant/$originSlug" - return "OPTABLE_" + kind + "_" + Base64.encodeToString(sfx.toByteArray(), 0) - } - -} \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/GoogleAdIdManager.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/GoogleAdIdManager.kt index e3f46c1..adffa92 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/GoogleAdIdManager.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/GoogleAdIdManager.kt @@ -1,15 +1,14 @@ package co.optable.android_sdk.core -import android.content.Context import android.text.TextUtils +import co.optable.android_sdk.OptableConfig import com.google.android.gms.ads.identifier.AdvertisingIdClient import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch internal class GoogleAdIdManager( - config: Config, - private val context: Context, + val config: OptableConfig, ) { private var adId: String? = null @@ -25,7 +24,7 @@ internal class GoogleAdIdManager( GlobalScope.launch { var adInfo: AdvertisingIdClient.Info? = null try { - adInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) + adInfo = AdvertisingIdClient.getAdvertisingIdInfo(config.context) } catch (_: Exception) { } diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/LocalStorage.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/LocalStorage.kt index 18e67b4..d30eabc 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/LocalStorage.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/LocalStorage.kt @@ -4,20 +4,19 @@ */ package co.optable.android_sdk.core -import android.content.Context import androidx.preference.PreferenceManager +import co.optable.android_sdk.OptableConfig import co.optable.android_sdk.OptableTargetingResponse import com.google.gson.Gson import com.google.gson.reflect.TypeToken internal class LocalStorage( - private val config: Config, - context: Context, + config: OptableConfig, ) { - private val prefs = PreferenceManager.getDefaultSharedPreferences(context) - private val passportKey = this.config.passportKey() - private val targetingKey = this.config.targetingKey() + private val prefs = PreferenceManager.getDefaultSharedPreferences(config.context) + private val passportKey = TypeHasher.passportKey(config) + private val targetingKey = TypeHasher.targetingKey(config) fun getPassport(): String? { return prefs.getString(passportKey, null) diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt index 48d780b..d053641 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/NetworkClient.kt @@ -13,7 +13,7 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory internal class NetworkClient( - config: Config, + config: OptableConfig, requestInterceptor: RequestInterceptor, responseInterceptor: ResponseInterceptor, ) { @@ -27,7 +27,7 @@ internal class NetworkClient( .build() val retrofit = Retrofit.Builder() - .baseUrl(config.edgeBaseURL()) + .baseUrl(config.getBaseUrl()) .addCallAdapterFactory(EdgeResponseAdapterFactory()) .addConverterFactory(GsonConverterFactory.create()) .client(client) diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt index c52e3bc..74ad7f2 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/RequestInterceptor.kt @@ -1,11 +1,12 @@ package co.optable.android_sdk.core import co.optable.BuildConfig +import co.optable.android_sdk.OptableConfig import okhttp3.Interceptor import okhttp3.Response internal class RequestInterceptor( - private val config: Config, + private val config: OptableConfig, private val storage: LocalStorage, private val userAgentHolder: UserAgentHolder, ) : Interceptor { diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/TypeHasher.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/TypeHasher.kt index 3720ee0..805bc48 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/TypeHasher.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/TypeHasher.kt @@ -1,6 +1,8 @@ package co.optable.android_sdk.core import android.net.Uri +import android.util.Base64 +import co.optable.android_sdk.OptableConfig import java.security.MessageDigest import java.util.Locale.getDefault @@ -53,4 +55,17 @@ object TypeHasher { return "e:" + oeid.lowercase(getDefault()) } + fun passportKey(config: OptableConfig): String { + return key("PASS", config) + } + + fun targetingKey(config: OptableConfig): String { + return key("TGT", config) + } + + private fun key(kind: String, config: OptableConfig): String { + val sfx = "${config.host}/${config.tenant}/${config.originSlug}" + return "OPTABLE_" + kind + "_" + Base64.encodeToString(sfx.toByteArray(), 0) + } + } \ No newline at end of file diff --git a/android_sdk/src/main/java/co/optable/android_sdk/core/UserAgentHolder.kt b/android_sdk/src/main/java/co/optable/android_sdk/core/UserAgentHolder.kt index bdfc596..07c3831 100644 --- a/android_sdk/src/main/java/co/optable/android_sdk/core/UserAgentHolder.kt +++ b/android_sdk/src/main/java/co/optable/android_sdk/core/UserAgentHolder.kt @@ -2,13 +2,13 @@ package co.optable.android_sdk.core import android.content.Context import android.webkit.WebView +import co.optable.android_sdk.OptableConfig internal class UserAgentHolder( - customUserAgent: String? = null, - context: Context, + config: OptableConfig, ) { - private val cachedUserAgent: String? = customUserAgent ?: userAgentFromWebView(context) + private val cachedUserAgent: String? = config.customUserAgent ?: userAgentFromWebView(config.context) fun getUserAgent() = cachedUserAgent diff --git a/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt b/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt index 2fcbe0a..d632724 100644 --- a/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt +++ b/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt @@ -21,7 +21,7 @@ import org.mockito.kotlin.whenever class MockWebServerTest { @Mock - private lateinit var config: Config + private lateinit var config: OptableConfig @Mock private lateinit var storage: LocalStorage From 5ae14c5c7c4d8cd4dd92d6d98ce3182996a21725 Mon Sep 17 00:00:00 2001 From: Valentin Petrovych Date: Mon, 15 Dec 2025 13:51:25 +0100 Subject: [PATCH 07/22] fix: unit tests --- .../test/java/co/optable/android_sdk/core/MockWebServerTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt b/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt index d632724..fba26ec 100644 --- a/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt +++ b/android_sdk/src/test/java/co/optable/android_sdk/core/MockWebServerTest.kt @@ -1,6 +1,7 @@ package co.optable.android_sdk.core import co.optable.BuildConfig +import co.optable.android_sdk.OptableConfig import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request From ec71ba5bc091d7ccab9f6f8b05903dfe05d9ef5a Mon Sep 17 00:00:00 2001 From: Valentin Petrovych <72038591+ValentinPostindustria@users.noreply.github.com> Date: Thu, 18 Dec 2025 12:35:47 +0100 Subject: [PATCH 08/22] Introduce Optable demo cases with Prebid (#32) --- DemoApp/DemoAppJava/app/build.gradle | 3 + .../co/optable/demoappjava/MainActivity.java | 30 ++- .../ui/GAMBanner/GAMBannerFragment.java | 2 +- .../demoappjava/ui/PrebidBannerFragment.java | 221 ++++++++++++++++++ .../app/src/main/res/layout/activity_main.xml | 34 ++- .../main/res/layout/fragment_gambanner.xml | 2 +- .../src/main/res/layout/fragment_identify.xml | 1 + .../src/main/res/layout/fragment_prebid.xml | 61 +++++ .../app/src/main/res/menu/bottom_nav_menu.xml | 5 + .../main/res/navigation/mobile_navigation.xml | 6 + DemoApp/DemoAppKotlin/app/build.gradle | 3 + .../co/optable/androidsdkdemo/MainActivity.kt | 25 ++ .../ui/GAMBanner/GAMBannerFragment.kt | 4 +- .../androidsdkdemo/ui/PrebidBannerFragment.kt | 214 +++++++++++++++++ .../app/src/main/res/layout/activity_main.xml | 34 ++- .../main/res/layout/fragment_gambanner.xml | 10 +- .../src/main/res/layout/fragment_identify.xml | 1 + .../src/main/res/layout/fragment_prebid.xml | 61 +++++ .../app/src/main/res/menu/bottom_nav_menu.xml | 9 +- .../main/res/navigation/mobile_navigation.xml | 10 +- 20 files changed, 680 insertions(+), 56 deletions(-) create mode 100644 DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/ui/PrebidBannerFragment.java create mode 100644 DemoApp/DemoAppJava/app/src/main/res/layout/fragment_prebid.xml create mode 100644 DemoApp/DemoAppKotlin/app/src/main/java/co/optable/androidsdkdemo/ui/PrebidBannerFragment.kt create mode 100644 DemoApp/DemoAppKotlin/app/src/main/res/layout/fragment_prebid.xml diff --git a/DemoApp/DemoAppJava/app/build.gradle b/DemoApp/DemoAppJava/app/build.gradle index bbadf30..498bbfa 100644 --- a/DemoApp/DemoAppJava/app/build.gradle +++ b/DemoApp/DemoAppJava/app/build.gradle @@ -50,6 +50,9 @@ dependencies { // Google Mobile Ads implementation 'com.google.android.gms:play-services-ads:24.6.0' + // Prebid Ads + implementation "org.prebid:prebid-mobile-sdk:3.0.2" + // Base Android implementation "org.jetbrains.kotlin:kotlin-stdlib:2.0.21" implementation 'com.android.support:multidex:1.0.3' diff --git a/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java b/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java index 90235cd..2a41db4 100644 --- a/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java +++ b/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/MainActivity.java @@ -1,6 +1,7 @@ package co.optable.demoappjava; import android.os.Bundle; +import android.util.Log; import androidx.appcompat.app.AppCompatActivity; import androidx.navigation.NavController; import androidx.navigation.Navigation; @@ -8,10 +9,15 @@ import androidx.navigation.ui.NavigationUI; import co.optable.android_sdk.OptableConfig; import co.optable.android_sdk.OptableSDK; +import com.google.android.gms.ads.MobileAds; import com.google.android.material.bottomnavigation.BottomNavigationView; +import org.prebid.mobile.PrebidMobile; +import org.prebid.mobile.api.data.InitializationStatus; public class MainActivity extends AppCompatActivity { + private static final String TAG = "MainActivity"; + public static OptableSDK OPTABLE; @Override @@ -22,13 +28,35 @@ protected void onCreate(Bundle savedInstanceState) { OptableConfig config = new OptableConfig(this, "prebidtest", "js-sdk"); MainActivity.OPTABLE = new OptableSDK(config); + initGoogleAds(); + initPrebidSdk(); initUi(); } + private void initGoogleAds() { + MobileAds.initialize(this, initializationStatus -> { + }); + } + + private void initPrebidSdk() { + PrebidMobile.setPrebidServerAccountId("0689a263-318d-448b-a3d4-b02e8a709d9d"); + PrebidMobile.initializeSdk(getApplicationContext(), "https://prebid-server-test-j.prebid.org/openrtb2/auction", status -> { + if (status == InitializationStatus.SUCCEEDED) { + Log.d(TAG, "SDK initialized successfully!"); + } else { + Log.e(TAG, "SDK initialization error: " + status.getDescription()); + } + }); + } + private void initUi() { BottomNavigationView navView = findViewById(R.id.nav_view); NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment); - AppBarConfiguration appBarConfiguration = new AppBarConfiguration.Builder(R.id.navigation_identify, R.id.navigation_gambanner).build(); + AppBarConfiguration appBarConfiguration = new AppBarConfiguration.Builder( + R.id.navigation_identify, + R.id.navigation_gambanner, + R.id.navigation_prebid + ).build(); NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration); NavigationUI.setupWithNavController(navView, navController); } diff --git a/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/ui/GAMBanner/GAMBannerFragment.java b/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/ui/GAMBanner/GAMBannerFragment.java index d717c4f..10112e0 100644 --- a/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/ui/GAMBanner/GAMBannerFragment.java +++ b/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/ui/GAMBanner/GAMBannerFragment.java @@ -34,7 +34,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, private void initUi(View root) { mAdView = root.findViewById(R.id.publisherAdView); - statusTextView = root.findViewById(R.id.targetingDataView); + statusTextView = root.findViewById(R.id.statusTextView); root.findViewById(R.id.btnLoadBanner).setOnClickListener(view -> onClickLoadAd()); root.findViewById(R.id.btnCachedBanner).setOnClickListener(view -> onClickCachedBanner()); diff --git a/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/ui/PrebidBannerFragment.java b/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/ui/PrebidBannerFragment.java new file mode 100644 index 0000000..a6bdd28 --- /dev/null +++ b/DemoApp/DemoAppJava/app/src/main/java/co/optable/demoappjava/ui/PrebidBannerFragment.java @@ -0,0 +1,221 @@ +package co.optable.demoappjava.ui; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import co.optable.android_sdk.OptableSDK; +import co.optable.demoappjava.MainActivity; +import co.optable.demoappjava.R; +import com.google.android.gms.ads.AdListener; +import com.google.android.gms.ads.LoadAdError; +import com.google.android.gms.ads.admanager.AdManagerAdRequest; +import com.google.android.gms.ads.admanager.AdManagerAdView; +import org.jetbrains.annotations.NotNull; +import org.prebid.mobile.BannerAdUnit; +import org.prebid.mobile.ExternalUserId; +import org.prebid.mobile.TargetingParams; + +import java.util.*; + +public class PrebidBannerFragment extends Fragment { + + private static final String GAM_AD_UNIT_ID = "/21808260008/prebid_demo_app_original_api_banner"; + private static final String PREBID_CONFIG_ID = "prebid-demo-banner-320-50"; + private static final int WIDTH = 320; + private static final int HEIGHT = 50; + + private AdManagerAdView adView; + private BannerAdUnit prebidAdUnit; + + private ViewGroup adContainer; + private TextView statusTextView; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.fragment_prebid, container, false); + initUi(root); + return root; + } + + private void initUi(View root) { + statusTextView = root.findViewById(R.id.statusTextView); + adContainer = root.findViewById(R.id.adContainer); + + root.findViewById(R.id.btnLoadBanner).setOnClickListener(view -> onClickLoadAd()); + root.findViewById(R.id.btnCachedBanner).setOnClickListener(view -> onClickCachedBanner()); + root.findViewById(R.id.btnClearCache).setOnClickListener(view -> onClickClearCache()); + } + + /** + * Loads targeting data and then the GAM banner + */ + private void onClickLoadAd() { + statusTextView.setText(""); + + MainActivity.OPTABLE + .targeting() + .observe(getViewLifecycleOwner(), result -> { + AdManagerAdRequest.Builder adRequestBuilder = new AdManagerAdRequest.Builder(); + + if (result.getStatus() == OptableSDK.Status.SUCCESS) { + HashMap> data = result.getData(); + changeStatusText("Loaded Optable targeting data", data); + + if (data != null) { + for (String key : data.keySet()) { + List values = data.get(key); + if (values == null) continue; + adRequestBuilder.addCustomTargeting(key, values); + } + } + } else { + changeStatusText("Error loading Optable targeting data: " + result.getMessage(), null); + } + + loadPrebidAd(adRequestBuilder, result.getData()); + profile(); + witness(); + }); + } + + private void loadPrebidAd(AdManagerAdRequest.Builder adRequestBuilder, @Nullable HashMap> optableTargeting) { + prebidAdUnit = new BannerAdUnit(PREBID_CONFIG_ID, WIDTH, HEIGHT); + applyOptableToPrebid(optableTargeting); + prebidAdUnit.fetchDemand(adRequestBuilder, resultCode -> { + appendStatusText("Prebid ads loading status: " + resultCode.toString()); + loadGamAd(adRequestBuilder); + }); + } + + private void applyOptableToPrebid(HashMap> optableTargeting) { + if (optableTargeting != null) { + List uniqueIds = new ArrayList<>(); + for (Map.Entry> entry : optableTargeting.entrySet()) { + List value = entry.getValue(); + if (value == null) continue; + for (String id : value) { + uniqueIds.add(new ExternalUserId.UniqueId(id, 1)); + } + } + List externalUserIds = new ArrayList<>(); + externalUserIds.add(new ExternalUserId("optable.com", uniqueIds)); + TargetingParams.setExternalUserIds(externalUserIds); + } + } + + private void loadGamAd(AdManagerAdRequest.Builder adRequestBuilder) { + adContainer.removeAllViews(); + + AdManagerAdRequest adRequest = adRequestBuilder.build(); + + adView = new AdManagerAdView(requireContext()); + adView.setAdUnitId(GAM_AD_UNIT_ID); + adView.setAdSizes(new com.google.android.gms.ads.AdSize(WIDTH, HEIGHT)); + adView.setAdListener(new AdListener() { + @Override + public void onAdLoaded() { + super.onAdLoaded(); + appendStatusText("Google ad loaded"); + } + + @Override + public void onAdFailedToLoad(@NonNull @NotNull LoadAdError loadAdError) { + appendStatusText("Google ad failed to load: " + loadAdError.getMessage()); + } + }); + adView.loadAd(adRequest); + + adContainer.addView(adView); + } + + /** + * Loads targeting data from cache and then the GAM banner + */ + private void onClickCachedBanner() { + statusTextView.setText(""); + + AdManagerAdRequest.Builder adRequestBuilder = new AdManagerAdRequest.Builder(); + HashMap> data = MainActivity.OPTABLE.targetingFromCache(); + + if (data != null) { + changeStatusText("Loaded Optable cached targeting data", data); + for (String key : data.keySet()) { + List values = data.get(key); + if (values == null) continue; + adRequestBuilder.addCustomTargeting(key, values); + } + } else { + changeStatusText("Targeting data cache empty.", null); + } + + loadPrebidAd(adRequestBuilder, data); + profile(); + witness(); + } + + /** + * Clears targeting data cache. + */ + private void onClickClearCache() { + statusTextView.setText("Clearing targeting data cache.\n\n"); + MainActivity.OPTABLE.targetingClearCache(); + } + + private void profile() { + HashMap traits = new HashMap<>(); + traits.put("gender", "F"); + traits.put("age", 38); + traits.put("hasAccount", true); + + MainActivity.OPTABLE + .profile(traits) + .observe(getViewLifecycleOwner(), result -> { + if (result.getStatus() == OptableSDK.Status.SUCCESS) { + appendStatusText("Success calling profile API to set traits on user."); + } else { + appendStatusText("Error during sending profile: " + result.getMessage()); + } + }); + } + + private void witness() { + HashMap eventProperties = new HashMap<>(); + eventProperties.put("exampleKey", "exampleValue"); + eventProperties.put("exampleKey2", 123); + eventProperties.put("exampleKey3", false); + + MainActivity.OPTABLE + .witness("GAMBannerFragment.loadAdButtonClicked", eventProperties) + .observe(getViewLifecycleOwner(), result -> { + if (result.getStatus() == OptableSDK.Status.SUCCESS) { + appendStatusText("Success calling witness API to log loadAdButtonClicked event."); + } else { + appendStatusText("Error during sending witness: " + result.getMessage()); + } + }); + } + + private void changeStatusText(@NonNull String message, @Nullable HashMap> optableResponse) { + StringBuilder formattedMessage = new StringBuilder(message); + if (optableResponse != null) { + formattedMessage.append("\n\nTargeting data: "); + for (Map.Entry> entry : optableResponse.entrySet()) { + formattedMessage.append(entry.getKey()) + .append(" = ") + .append(entry.getValue()) + .append("\n"); + } + } + statusTextView.setText(formattedMessage.toString()); + } + + private void appendStatusText(@NonNull String message) { + statusTextView.append("\n\n" + message); + } + +} \ No newline at end of file diff --git a/DemoApp/DemoAppJava/app/src/main/res/layout/activity_main.xml b/DemoApp/DemoAppJava/app/src/main/res/layout/activity_main.xml index 38c0aa8..472e2b8 100644 --- a/DemoApp/DemoAppJava/app/src/main/res/layout/activity_main.xml +++ b/DemoApp/DemoAppJava/app/src/main/res/layout/activity_main.xml @@ -1,33 +1,25 @@ - - - + android:orientation="vertical" + android:layout_height="match_parent"> - \ No newline at end of file + + + \ No newline at end of file diff --git a/DemoApp/DemoAppJava/app/src/main/res/layout/fragment_gambanner.xml b/DemoApp/DemoAppJava/app/src/main/res/layout/fragment_gambanner.xml index 2fe5cbc..1bfeb90 100644 --- a/DemoApp/DemoAppJava/app/src/main/res/layout/fragment_gambanner.xml +++ b/DemoApp/DemoAppJava/app/src/main/res/layout/fragment_gambanner.xml @@ -57,7 +57,7 @@ app:layout_constraintTop_toTopOf="parent" /> + + + + +