[UI Lib] Add support for LocalAdapter::openSession()
This CL also adds proxy for SessionClient and Session class. I haven't
added proxy for every method yet. More to come in the second CL.
This is the bare minimum to launch our manual test app on Android 10
without any crashes.
Updated our automated tests to run using both kinds of adapter. Since
our test always runs locally without creating an actual Sandbox, we have
to use hack to force the library into using RemoteAdapter.
Bug: 282918647
Test: ./gradlew :privacysandbox:ui:ui-tests:connectedAndroidTest --info
Test: Manual test on Android 10
Change-Id: Ia51c79fd140fa0728db38506742642c7c9476597
diff --git a/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt b/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt
index b917e46..b2761c4 100644
--- a/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt
+++ b/privacysandbox/ui/integration-tests/testsdkprovider/src/main/java/androidx/privacysandbox/ui/integration/testsdkprovider/SdkApi.kt
@@ -25,7 +25,9 @@
import android.graphics.Paint
import android.net.Uri
import android.os.Bundle
+import android.os.Handler
import android.os.IBinder
+import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.view.View
@@ -38,6 +40,7 @@
class SdkApi(sdkContext: Context) : ISdkApi.Stub() {
private var mContext: Context? = null
+ private val handler = Handler(Looper.getMainLooper())
init {
mContext = sdkContext
@@ -67,28 +70,32 @@
clientExecutor: Executor,
client: SandboxedUiAdapter.SessionClient,
) {
- Log.d(TAG, "Session requested")
- lateinit var adView: View
- if (isWebView) {
- // To test error cases.
- if (isAirplaneModeOn()) {
- clientExecutor.execute {
- client.onSessionError(Throwable("Cannot load WebView in airplane mode."))
+ handler.post(Runnable lambda@{
+ Log.d(TAG, "Session requested")
+ lateinit var adView: View
+ if (isWebView) {
+ // To test error cases.
+ if (isAirplaneModeOn()) {
+ clientExecutor.execute {
+ client.onSessionError(
+ Throwable("Cannot load WebView in airplane mode.")
+ )
+ }
+ return@lambda
}
- return
+ val webView = WebView(context)
+ webView.loadUrl(AD_URL)
+ webView.layoutParams = ViewGroup.LayoutParams(
+ initialWidth, initialHeight
+ )
+ adView = webView
+ } else {
+ adView = TestView(context, withSlowDraw, text)
}
- val webView = WebView(context)
- webView.loadUrl(AD_URL)
- webView.layoutParams = ViewGroup.LayoutParams(
- initialWidth, initialHeight
- )
- adView = webView
- } else {
- adView = TestView(context, withSlowDraw, text)
- }
- clientExecutor.execute {
- client.onSessionOpened(BannerAdSession(adView))
- }
+ clientExecutor.execute {
+ client.onSessionOpened(BannerAdSession(adView))
+ }
+ })
}
private inner class BannerAdSession(private val adView: View) : SandboxedUiAdapter.Session {
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
index 70ceae1..56c62c6 100644
--- a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
+++ b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
@@ -16,12 +16,14 @@
package androidx.privacysandbox.ui.client
+import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
+import android.util.Log
import android.view.Display
import android.view.SurfaceControlViewHost
import android.view.SurfaceView
@@ -31,6 +33,9 @@
import androidx.privacysandbox.ui.core.IRemoteSessionController
import androidx.privacysandbox.ui.core.ISandboxedUiAdapter
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
+import java.lang.reflect.InvocationHandler
+import java.lang.reflect.Method
+import java.lang.reflect.Proxy
import java.util.concurrent.Executor
/**
@@ -40,8 +45,10 @@
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
object SandboxedUiAdapterFactory {
+ private const val TAG = "PrivacySandboxUiLib"
// Bundle key is a binary compatibility requirement
private const val UI_ADAPTER_BINDER = "uiAdapterBinder"
+ private const val TEST_ONLY_USE_REMOTE_ADAPTER = "testOnlyUseRemoteAdapter"
/**
* @throws IllegalArgumentException if {@code coreLibInfo} does not contain a Binder with the
@@ -54,9 +61,149 @@
val adapterInterface = ISandboxedUiAdapter.Stub.asInterface(
uiAdapterBinder
)
- return RemoteAdapter(adapterInterface)
+
+ val forceUseRemoteAdapter = coreLibInfo.getBoolean(TEST_ONLY_USE_REMOTE_ADAPTER)
+ val isLocalBinder = uiAdapterBinder.queryLocalInterface(
+ ISandboxedUiAdapter.DESCRIPTOR) != null
+ val useLocalAdapter = !forceUseRemoteAdapter && isLocalBinder
+ Log.d(TAG, "useLocalAdapter=$useLocalAdapter")
+
+ return if (useLocalAdapter) {
+ LocalAdapter(adapterInterface)
+ } else {
+ RemoteAdapter(adapterInterface)
+ }
}
+ /**
+ * [LocalAdapter] fetches UI from a provider living on same process as the client but on a
+ * different class loader.
+ */
+ private class LocalAdapter(private val adapterInterface: ISandboxedUiAdapter) :
+ SandboxedUiAdapter {
+ private val uiProviderBinder = adapterInterface.asBinder()
+
+ @SuppressLint("BanUncheckedReflection") // using reflection on library classes
+ override fun openSession(
+ context: Context,
+ windowInputToken: IBinder,
+ initialWidth: Int,
+ initialHeight: Int,
+ isZOrderOnTop: Boolean,
+ clientExecutor: Executor,
+ client: SandboxedUiAdapter.SessionClient
+ ) {
+ try {
+ // openSession call needs to be forwarded to uiProvider object instantiated
+ // on a different classloader.
+ val uiProviderClassLoader = uiProviderBinder.javaClass.classLoader
+ val classOnUiProviderClassLoader = Class.forName(uiProviderBinder::class.java.name,
+ /*initialize=*/false, uiProviderBinder.javaClass.classLoader)
+ val openSessionMethod = classOnUiProviderClassLoader.methods.first {
+ method -> method.name.equals("openSession")
+ }
+
+ // We can't pass the client object as-is since it's been created on a different
+ // classloader.
+ val targetSessionClientClass = Class.forName(
+ SandboxedUiAdapter.SessionClient::class.java.name,
+ /*initialize=*/ false,
+ uiProviderClassLoader
+ )
+ val sessionClientProxy = Proxy.newProxyInstance(
+ uiProviderClassLoader,
+ arrayOf(targetSessionClientClass),
+ SessionClientProxyHandler(client)
+ )
+
+ openSessionMethod.invoke(uiProviderBinder, context, windowInputToken, initialWidth,
+ initialHeight, isZOrderOnTop, clientExecutor, sessionClientProxy)
+ } catch (exception: Throwable) {
+ client.onSessionError(exception)
+ }
+ }
+
+ private inner class SessionClientProxyHandler(
+ private val origClient: SandboxedUiAdapter.SessionClient,
+ ) : InvocationHandler {
+
+ @SuppressLint("BanUncheckedReflection") // using reflection on library classes
+ override fun invoke(proxy: Any, method: Method, args: Array<Any>?): Any? {
+ return when (method.name) {
+ "onSessionOpened" -> {
+ args!! // This method will always have an argument, so safe to !!
+
+ // We have to forward the call to original client, but it won't
+ // recognize Session class on targetClassLoader. We need another proxy.
+ val origSessionClass = Class.forName(
+ SandboxedUiAdapter.Session::class.java.name,
+ /*initialize=*/ false,
+ origClient.javaClass.classLoader
+ )
+ val sessionProxy = Proxy.newProxyInstance(
+ origClient.javaClass.classLoader,
+ arrayOf(origSessionClass),
+ SessionProxyHandler(args[0])
+ )
+
+ val methodOrig = origClient.javaClass.getMethod("onSessionOpened",
+ SandboxedUiAdapter.Session::class.java)
+ methodOrig.invoke(origClient, sessionProxy)
+ }
+ "onSessionError" -> {
+ args!! // This method will always have an argument, so safe to !!
+
+ val throwable = args[0] as Throwable
+ val methodOrig = origClient.javaClass.getMethod("onSessionError",
+ Throwable::class.java)
+ methodOrig.invoke(origClient, throwable)
+ }
+ "toString" -> {
+ origClient.javaClass.getMethod("toString").invoke(origClient)
+ }
+ else -> {
+ // TODO(b/282918647): Implement other methods required
+ throw UnsupportedOperationException(
+ "Unexpected method call object:$proxy, method: $method, args: $args"
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * Create [SandboxedUiAdapter.Session] on [targetClassLoader] that proxies to [origClient]
+ */
+ private inner class SessionProxyHandler(
+ private val origClient: Any,
+ ) : InvocationHandler {
+
+ @SuppressLint("BanUncheckedReflection") // using reflection on library classes
+ override fun invoke(proxy: Any, method: Method, args: Array<Any>?): Any? {
+ return when (method.name) {
+ "close" -> {
+ origClient.javaClass.getMethod("close").invoke(origClient)
+ }
+ "getView" -> {
+ origClient.javaClass.getMethod("getView").invoke(origClient)
+ }
+ "toString" -> {
+ origClient.javaClass.getMethod("toString").invoke(origClient)
+ }
+ else -> {
+ // TODO(b/282918647): Implement other methods required
+ throw UnsupportedOperationException(
+ "Unexpected method call object:$proxy, method: $method, args: $args"
+ )
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * [RemoteAdapter] fetches content from a provider living on a different process.
+ */
private class RemoteAdapter(private val adapterInterface: ISandboxedUiAdapter) :
SandboxedUiAdapter {
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkView.kt b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkView.kt
index 49c553f..7f5afe4 100644
--- a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkView.kt
+++ b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/view/SandboxedSdkView.kt
@@ -19,6 +19,7 @@
import android.content.Context
import android.content.res.Configuration
import android.graphics.Rect
+import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.util.AttributeSet
@@ -268,7 +269,12 @@
}
internal fun removeSurfaceViewAndOpenSession() {
- windowInputToken = surfaceView.hostToken
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ windowInputToken = surfaceView.hostToken
+ } else {
+ // Since there is no SdkSandbox, we don't need windowInputToken for creating SCVH
+ windowInputToken = Binder()
+ }
super.removeView(surfaceView)
checkClientOpenSession()
}
diff --git a/privacysandbox/ui/ui-tests/src/androidTest/java/androidx/privacysandbox/ui/tests/endtoend/IntegrationTests.kt b/privacysandbox/ui/ui-tests/src/androidTest/java/androidx/privacysandbox/ui/tests/endtoend/IntegrationTests.kt
index 1528e16..e39078f 100644
--- a/privacysandbox/ui/ui-tests/src/androidTest/java/androidx/privacysandbox/ui/tests/endtoend/IntegrationTests.kt
+++ b/privacysandbox/ui/ui-tests/src/androidTest/java/androidx/privacysandbox/ui/tests/endtoend/IntegrationTests.kt
@@ -21,6 +21,7 @@
import android.content.res.Configuration
import android.os.Binder
import android.os.Build
+import android.os.Bundle
import android.os.IBinder
import android.os.SystemClock
import android.view.MotionEvent
@@ -37,11 +38,11 @@
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import androidx.privacysandbox.ui.provider.toCoreLibInfo
import androidx.test.ext.junit.rules.ActivityScenarioRule
-import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.testutils.withActivity
import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
@@ -52,17 +53,26 @@
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
-@RunWith(AndroidJUnit4::class)
+@RunWith(Parameterized::class)
@MediumTest
-class IntegrationTests {
+class IntegrationTests(private val invokeBackwardsCompatFlow: Boolean) {
@get:Rule
var activityScenarioRule = ActivityScenarioRule(MainActivity::class.java)
companion object {
+ const val TEST_ONLY_USE_REMOTE_ADAPTER = "testOnlyUseRemoteAdapter"
const val TIMEOUT = 1000.toLong()
+
+ @JvmStatic
+ @Parameterized.Parameters(name = "{index}: invokeBackwardsCompatFlow={0}")
+ fun data(): Array<Any> = arrayOf(
+ arrayOf(true),
+ arrayOf(false),
+ )
}
private lateinit var context: Context
@@ -74,6 +84,7 @@
@Before
fun setup() {
Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
+
context = InstrumentationRegistry.getInstrumentation().context
activity = activityScenarioRule.withActivity { this }
view = SandboxedSdkView(context)
@@ -100,7 +111,7 @@
null,
false /* hasFailiningTestSession */
)
- val coreLibInfo = adapter.toCoreLibInfo(context)
+ val coreLibInfo = getCoreLibInfoFromAdapter(adapter)
val userRemoteAdapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
view.setAdapter(userRemoteAdapter)
@@ -145,15 +156,16 @@
}
@Test
- fun testSessionOpen() {
+ fun testOpenSession_onSetAdapter() {
val openSessionLatch = CountDownLatch(1)
val adapter = TestSandboxedUiAdapter(openSessionLatch, null, false)
- val coreLibInfo = adapter.toCoreLibInfo(context)
+ val coreLibInfo = getCoreLibInfoFromAdapter(adapter)
val userRemoteAdapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
view.setAdapter(userRemoteAdapter)
- openSessionLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)
- assertTrue(adapter.isOpenSessionCalled)
+ assertTrue(openSessionLatch.await(TIMEOUT, TimeUnit.MILLISECONDS))
+ assertWithMessage("openSession is called on adapter")
+ .that(adapter.isOpenSessionCalled).isTrue()
var isSessionInitialised = try {
adapter.session
true
@@ -164,10 +176,10 @@
}
@Test
- fun testOpenSessionFromAdapter() {
+ fun testOpenSession_fromAdapter() {
val openSessionLatch = CountDownLatch(1)
val adapter = TestSandboxedUiAdapter(openSessionLatch, null, false)
- val coreLibInfo = adapter.toCoreLibInfo(context)
+ val coreLibInfo = getCoreLibInfoFromAdapter(adapter)
val adapterFromCoreLibInfo = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
val testSessionClient = TestSandboxedUiAdapter.TestSessionClient()
@@ -195,7 +207,7 @@
configChangedLatch,
false
)
- val coreLibInfo = adapter.toCoreLibInfo(context)
+ val coreLibInfo = getCoreLibInfoFromAdapter(adapter)
val adapterFromCoreLibInfo = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
view.setAdapter(adapterFromCoreLibInfo)
activity.runOnUiThread {
@@ -264,15 +276,21 @@
val adapter = TestSandboxedUiAdapter(
null, null, true
)
- val coreLibInfo = adapter.toCoreLibInfo(context)
+ val coreLibInfo = getCoreLibInfoFromAdapter(adapter)
val adapterThatFailsToCreateUi =
SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
view.setAdapter(adapterThatFailsToCreateUi)
- errorLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)
+ assertThat(errorLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
assertTrue(stateChangeListener.currentState is SandboxedSdkUiSessionState.Error)
val errorMessage = (stateChangeListener.currentState as
SandboxedSdkUiSessionState.Error).throwable.message
- assertTrue(errorMessage == "Test Session Exception")
+ assertThat(errorMessage).isEqualTo("Test Session Exception")
+ }
+
+ private fun getCoreLibInfoFromAdapter(sdkAdapter: SandboxedUiAdapter): Bundle {
+ val bundle = sdkAdapter.toCoreLibInfo(context)
+ bundle.putBoolean(TEST_ONLY_USE_REMOTE_ADAPTER, !invokeBackwardsCompatFlow)
+ return bundle
}
private fun openSessionAndWaitToBeActive(initialZOrder: Boolean): TestSandboxedUiAdapter {
@@ -321,10 +339,18 @@
}
}
+ /**
+ * TestSandboxedUiAdapter provides content from a fake SDK to show on the host's UI.
+ *
+ * A [SandboxedUiAdapter] is supposed to fetch the content from SandboxedSdk, but we fake the
+ * source of content in this class.
+ *
+ * If [hasFailingTestSession] is true, the fake server side logic returns error.
+ */
class TestSandboxedUiAdapter(
- val openSessionLatch: CountDownLatch?,
+ private val openSessionLatch: CountDownLatch?,
val configChangedLatch: CountDownLatch?,
- val hasFailingTestSession: Boolean
+ private val hasFailingTestSession: Boolean
) : SandboxedUiAdapter {
var isOpenSessionCalled = false
@@ -355,6 +381,9 @@
openSessionLatch?.countDown()
}
+ /**
+ * A failing session that always sends error notice to the client when content is requested.
+ */
inner class FailingTestSession(
private val context: Context
) : SandboxedUiAdapter.Session {
@@ -390,10 +419,6 @@
}
}
- init {
- internalClient.onSessionOpened(this)
- }
-
override fun notifyResized(width: Int, height: Int) {
}