Introducing Delegating Adapters
Type of uiAdapter that enables refreshing sessions with a
SandboxedSdkView without the View owner's involvement. The uiAdapter can switch views
between other SandboxedUiAdapters.
To refresh a session with a SandboxedSdkView the adapter triggers an
event for a secondary client, and fully establishes a session with the
secondaryclient of the view before switching it to primary.
Bug: b/340471011
Relnote: "Introduce Delegating Adapters which are used by UiProviders
for switching to a new delegate uiProvider"
Test: manually tested refresh ads on the test app
Change-Id: I5f1c501d1f4018d0f5eba2bd569eafe065e824aa
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 c5370e1..b342ba3 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
@@ -18,8 +18,11 @@
import android.content.Context
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.os.Process
import androidx.privacysandbox.sdkruntime.core.controller.SdkSandboxControllerCompat
+import androidx.privacysandbox.ui.core.DelegatingSandboxedUiAdapter
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import androidx.privacysandbox.ui.integration.sdkproviderutils.PlayerViewProvider
import androidx.privacysandbox.ui.integration.sdkproviderutils.PlayerViewabilityHandler
@@ -30,9 +33,12 @@
import androidx.privacysandbox.ui.integration.testaidl.IMediateeSdkApi
import androidx.privacysandbox.ui.integration.testaidl.ISdkApi
import androidx.privacysandbox.ui.provider.toCoreLibInfo
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
class SdkApi(private val sdkContext: Context) : ISdkApi.Stub() {
private val testAdapters = TestAdapters(sdkContext)
+ private val handler = Handler(Looper.getMainLooper())
override fun loadBannerAd(
@AdType adType: Int,
@@ -43,16 +49,13 @@
val isMediation = mediationOption != MediationOption.NON_MEDIATED
val isAppOwnedMediation = (mediationOption == MediationOption.IN_APP_MEDIATEE)
if (isMediation) {
- val mediateeBundle =
- maybeGetMediateeBannerAdBundle(
+ return loadMediatedTestAd(
isAppOwnedMediation,
adType,
waitInsideOnDraw,
drawViewability
)
- return if (mediationOption == MediationOption.SDK_RUNTIME_MEDIATEE_WITH_OVERLAY) {
- testAdapters.OverlaidAd(mediateeBundle).toCoreLibInfo(sdkContext)
- } else mediateeBundle
+ .toCoreLibInfo(sdkContext)
}
val adapter: SandboxedUiAdapter =
when (adType) {
@@ -73,6 +76,47 @@
return adapter.toCoreLibInfo(sdkContext)
}
+ private fun startDelegatingAdUpdateHandler(
+ adapter: DelegatingSandboxedUiAdapter,
+ drawViewability: Boolean
+ ) {
+ val updateInterval = UPDATE_DELEGATE_INTERVAL
+
+ val displayAdFromRuntimeMediatee = Runnable {
+ val coroutineScope = MainScope()
+ coroutineScope.launch {
+ val runtimeAdapterBundle =
+ maybeGetMediateeBannerAdBundle(
+ false,
+ AdType.BASIC_NON_WEBVIEW,
+ false,
+ drawViewability
+ )
+ adapter.updateDelegate(runtimeAdapterBundle)
+ }
+ }
+ val displayAdFromAppOwnedMediatee = Runnable {
+ val coroutineScope = MainScope()
+ coroutineScope.launch {
+ val inAppAdapterBundle =
+ maybeGetMediateeBannerAdBundle(
+ true,
+ AdType.BASIC_NON_WEBVIEW,
+ false,
+ drawViewability
+ )
+ adapter.updateDelegate(inAppAdapterBundle)
+ }
+ }
+ // Post events to update the delegate after certain intervals
+ handler.postDelayed(displayAdFromRuntimeMediatee, updateInterval)
+ // race condition
+ handler.postDelayed(displayAdFromRuntimeMediatee, 2 * updateInterval)
+ handler.postDelayed(displayAdFromAppOwnedMediatee, 2 * updateInterval)
+
+ handler.postDelayed(displayAdFromRuntimeMediatee, 4 * updateInterval)
+ }
+
/** Kill sandbox process */
override fun triggerProcessDeath() {
Process.killProcess(Process.myPid())
@@ -100,6 +144,22 @@
return adapter
}
+ private fun loadMediatedTestAd(
+ isAppMediatee: Boolean,
+ @AdType adType: Int,
+ waitInsideOnDraw: Boolean,
+ drawViewability: Boolean
+ ): SandboxedUiAdapter {
+ // TODO(b/350473804): Clean up mediatee flag - redundant after introducing Delegating
+ // adapters
+ val mediateeBannerAdBundle =
+ maybeGetMediateeBannerAdBundle(isAppMediatee, adType, waitInsideOnDraw, drawViewability)
+ val bannerAd = DelegatingSandboxedUiAdapter(mediateeBannerAdBundle)
+ // The ad will keep refreshing between different mediatees
+ startDelegatingAdUpdateHandler(bannerAd, drawViewability)
+ return bannerAd
+ }
+
override fun requestResize(width: Int, height: Int) {}
private fun maybeGetMediateeBannerAdBundle(
@@ -144,6 +204,6 @@
companion object {
private const val MEDIATEE_SDK =
"androidx.privacysandbox.ui.integration.mediateesdkprovider"
- private const val TAG = "SdkApi"
+ private const val UPDATE_DELEGATE_INTERVAL: Long = 5000L
}
}
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/ClientDelegatingAdapter.kt b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/ClientDelegatingAdapter.kt
new file mode 100644
index 0000000..ae62ff3
--- /dev/null
+++ b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/ClientDelegatingAdapter.kt
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.client
+
+import android.content.Context
+import android.os.Bundle
+import android.os.IBinder
+import androidx.annotation.GuardedBy
+import androidx.core.util.Consumer
+import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory.createFromCoreLibInfo
+import androidx.privacysandbox.ui.client.view.RefreshableSessionClient
+import androidx.privacysandbox.ui.core.IDelegateChangeListener
+import androidx.privacysandbox.ui.core.IDelegatingSandboxedUiAdapter
+import androidx.privacysandbox.ui.core.IDelegatorCallback
+import androidx.privacysandbox.ui.core.ISessionRefreshCallback
+import androidx.privacysandbox.ui.core.SandboxedUiAdapter
+import androidx.privacysandbox.ui.core.SessionObserverFactory
+import java.util.concurrent.Executor
+import kotlin.coroutines.resume
+import kotlinx.coroutines.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/**
+ * Client side class for [IDelegatingSandboxedUiAdapter] which helps open sessions with the
+ * delegate, and refresh sessions on the client when the delegate is updated by the provider.
+ *
+ * This class:
+ * 1. Takes opensession calls from the SandboxedSdkView and helps open session with the delegate
+ * 2. Takes onDelegateChanged requests from the provider and a. order refreshes on all active
+ * clients for this adapter b. communicates back success/failure if all clients refreshed
+ * successfully or some failed.
+ */
+internal class ClientDelegatingAdapter(
+ private val delegatingAdapterInterface: IDelegatingSandboxedUiAdapter,
+ /**
+ * Has the latest delegate that has been set on the delegating adapter. There may be race
+ * conditions on opening a session with the delegate and changing the delegate so, a lock must
+ * be acquired when dealing with this variable.
+ */
+ @GuardedBy("lock") var latestDelegate: SandboxedUiAdapter
+) : SandboxedUiAdapter {
+ private val lock = Any()
+
+ // Using Dispatcher.Unconfined implies the coroutine may resume on any available thread.
+ // The thread doesn't matter to us for this case and so between the two options we have - Main
+ // or Unconfined we prefer Unconfined.
+ private val scope = CoroutineScope(Dispatchers.Unconfined + NonCancellable)
+
+ /**
+ * List of SandboxedSdkView owned active session clients that are served using this adapter.
+ * Calls for refreshing a client and adding a client after an onSessionOpened is received can
+ * race. We may end up taking clients on old delegates which miss the refresh if locks aren't
+ * used.
+ */
+ @GuardedBy("lock")
+ private val sessionClients: MutableList<RefreshableSessionClient> = mutableListOf()
+
+ /**
+ * An observer receives change requests from the ui provider delegator. The calls to attach and
+ * detach an observer may race and so this has been guarded by locks.
+ */
+ @GuardedBy("lock") private var observer: IDelegateChangeListener? = null
+
+ /**
+ * Wrapper of the SandboxedSdkView's clients so that all calls are intercepted here and the
+ * client calls to open a session and refreshing the delegates can be handled well.
+ */
+ private inner class ClientWrapper(
+ private var client: RefreshableSessionClient?,
+ /** the delegate that was used when this [ClientWrapper] was created to open a session. */
+ private val delegateUsed: SandboxedUiAdapter
+ ) : SandboxedUiAdapter.SessionClient {
+
+ // It is possible that onSessionError and onSessionOpened race in certain conditions so,
+ // taking locks to ensure we process one completely before the other.
+ override fun onSessionOpened(session: SandboxedUiAdapter.Session) {
+ synchronized(lock) {
+ if (latestDelegate != delegateUsed) {
+ // TODO(b/351341237): Handle client retries
+ client?.onSessionError(
+ Throwable(
+ "The client may retry. The delegate changed while opening a new session." +
+ " This happens only when the adapter is being reused for a new SandboxedSdkView. " +
+ " Will not happen when switching sessions on existing SandboxedSdkViews"
+ )
+ )
+ return
+ }
+ client?.onSessionOpened(session)
+ if (observer == null) {
+ createNewDelegateChangeObserver()
+ delegatingAdapterInterface.addDelegateChangeListener(observer)
+ }
+ client?.let { sessionClients.add(it) }
+ }
+ }
+
+ /**
+ * Forwards the error to the underlying client. Since this client is no longer active, we
+ * remove it from the list of clients If the list is empty, this adapter has no clients, we
+ * detach the observer for delegate change as it may be purposeless.
+ */
+ override fun onSessionError(throwable: Throwable) {
+ synchronized(lock) {
+ sessionClients.remove(client)
+ if (sessionClients.isEmpty() && observer != null) {
+ delegatingAdapterInterface.removeDelegateChangeListener(observer)
+ observer = null
+ }
+ client?.onSessionError(throwable)
+ client = null
+ }
+ }
+
+ override fun onResizeRequested(width: Int, height: Int) {
+ client?.onResizeRequested(width, height)
+ }
+ }
+
+ /**
+ * Wraps the client received for opening a session with its own wrapper and then opens a session
+ * with the delegate If the observer is missing, it also attaches a delegate change observer to
+ * the adapter.
+ */
+ override fun openSession(
+ context: Context,
+ windowInputToken: IBinder,
+ initialWidth: Int,
+ initialHeight: Int,
+ isZOrderOnTop: Boolean,
+ clientExecutor: Executor,
+ client: SandboxedUiAdapter.SessionClient
+ ) {
+ val delegateUsed: SandboxedUiAdapter = synchronized(lock) { latestDelegate }
+ delegateUsed.openSession(
+ context,
+ windowInputToken,
+ initialWidth,
+ initialHeight,
+ isZOrderOnTop,
+ clientExecutor,
+ ClientWrapper(client as RefreshableSessionClient, delegateUsed)
+ )
+ }
+
+ /**
+ * The observer accepts [IDelegateChangeListener.onDelegateChanged] calls from the adapter and
+ * processes the change on its active clients. We have added reentrancy protection so other
+ * refresh requests will be queued until all responses are received. We forward
+ * success/failure(if any client fails) to the adapter.
+ */
+ @GuardedBy("lock")
+ private fun createNewDelegateChangeObserver() {
+ observer =
+ object : IDelegateChangeListener.Stub() {
+ private val toDispatch: MutableList<RefreshableSessionClient> = mutableListOf()
+ private val mutex = Mutex()
+
+ override fun onDelegateChanged(delegate: Bundle, callback: IDelegatorCallback) {
+ scope.launch { // Launch the refresh operation
+ try {
+ mutex.withLock { // Use mutex for reentrancy protection
+ synchronized(lock) {
+ latestDelegate = createFromCoreLibInfo(delegate)
+ toDispatch.addAll(sessionClients)
+ }
+ val results =
+ toDispatch
+ .map { dispatchClient ->
+ async {
+ requestRefresh(
+ dispatchClient
+ ) // suspend and return its result
+ }
+ }
+ .awaitAll()
+ // Determine overall success
+ val overallSuccess = results.all { it }
+ callback.onDelegateChangeResult(overallSuccess)
+ }
+ } catch (e: Exception) {
+ callback.onDelegateChangeResult(false)
+ } finally {
+ toDispatch.clear()
+ }
+ }
+ }
+
+ private fun createConsumer(binder: ISessionRefreshCallback): Consumer<Boolean> {
+ return Consumer { result -> binder.onRefreshResult(result) }
+ }
+
+ private suspend fun requestRefresh(
+ dispatchClient: RefreshableSessionClient
+ ): Boolean {
+ val isRefreshSuccessful = suspendCancellableCoroutine { continuation ->
+ dispatchClient.onSessionRefreshRequested(
+ createConsumer(
+ object : ISessionRefreshCallback.Stub() {
+ override fun onRefreshResult(success: Boolean) {
+ if (success) {
+ synchronized(lock) {
+ sessionClients.remove(dispatchClient)
+ }
+ }
+ continuation.resume(success)
+ }
+ }
+ )
+ )
+ }
+ return isRefreshSuccessful
+ }
+ }
+ }
+
+ override fun addObserverFactory(sessionObserverFactory: SessionObserverFactory) {}
+
+ override fun removeObserverFactory(sessionObserverFactory: SessionObserverFactory) {}
+}
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 09be59b..0b96ff2 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
@@ -33,9 +33,11 @@
import androidx.privacysandbox.ui.client.RemoteCallManager.addBinderDeathListener
import androidx.privacysandbox.ui.client.RemoteCallManager.closeRemoteSession
import androidx.privacysandbox.ui.client.RemoteCallManager.tryToCallRemoteObject
+import androidx.privacysandbox.ui.core.IDelegatingSandboxedUiAdapter
import androidx.privacysandbox.ui.core.IRemoteSessionClient
import androidx.privacysandbox.ui.core.IRemoteSessionController
import androidx.privacysandbox.ui.core.ISandboxedUiAdapter
+import androidx.privacysandbox.ui.core.ProtocolConstants
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import androidx.privacysandbox.ui.core.SessionObserverFactory
import java.lang.reflect.InvocationHandler
@@ -64,7 +66,24 @@
"Invalid bundle, missing $UI_ADAPTER_BINDER."
}
val adapterInterface = ISandboxedUiAdapter.Stub.asInterface(uiAdapterBinder)
-
+ // the following check for DelegatingAdapter check must happen before the checks for
+ // remote/local binder as the checks below have fallback to a RemoteAdapter if it's not
+ // local.
+ if (
+ uiAdapterBinder.interfaceDescriptor?.equals(
+ "androidx.privacysandbox.ui.core.IDelegatingSandboxedUiAdapter"
+ ) == true
+ ) {
+ val delegate =
+ coreLibInfo.getBundle(ProtocolConstants.delegateKey)
+ ?: throw UnsupportedOperationException(
+ "DelegatingAdapter must have a non null delegate"
+ )
+ return ClientDelegatingAdapter(
+ IDelegatingSandboxedUiAdapter.Stub.asInterface(uiAdapterBinder),
+ createFromCoreLibInfo(delegate)
+ )
+ }
val forceUseRemoteAdapter = coreLibInfo.getBoolean(TEST_ONLY_USE_REMOTE_ADAPTER)
val isLocalBinder =
uiAdapterBinder.queryLocalInterface(ISandboxedUiAdapter.DESCRIPTOR) != null
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 98e46be..c66933f 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
@@ -32,6 +32,7 @@
import android.view.ViewTreeObserver
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
+import androidx.core.util.Consumer
import androidx.customview.poolingcontainer.PoolingContainerListener
import androidx.customview.poolingcontainer.addPoolingContainerListener
import androidx.customview.poolingcontainer.isPoolingContainer
@@ -41,6 +42,7 @@
import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionState.Idle
import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionState.Loading
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
+import androidx.privacysandbox.ui.core.SandboxedUiAdapter.SessionClient
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.min
@@ -96,6 +98,16 @@
class Error(val throwable: Throwable) : SandboxedSdkUiSessionState()
}
+/** A type of client that may get refresh requests (to re-establish a session) */
+internal interface RefreshableSessionClient : SessionClient {
+ /**
+ * Called when the provider of content wants to refresh the ui session it holds.
+ *
+ * @param callback delivers success/failure of the refresh
+ */
+ fun onSessionRefreshRequested(callback: Consumer<Boolean>)
+}
+
class SandboxedSdkView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
ViewGroup(context, attrs) {
@@ -121,8 +133,10 @@
private var adapter: SandboxedUiAdapter? = null
private var client: Client? = null
+ private var clientSecondary: Client? = null
private var isZOrderOnTop = true
private var contentView: View? = null
+ private var refreshCallback: Consumer<Boolean>? = null
private var requestedWidth = -1
private var requestedHeight = -1
private var isTransitionGroupSet = false
@@ -205,27 +219,43 @@
return false
}
- private fun checkClientOpenSession() {
+ private fun checkClientOpenSession(
+ isSecondary: Boolean = false,
+ callback: Consumer<Boolean>? = null
+ ) {
val adapter = adapter
if (
- client == null &&
- adapter != null &&
+ adapter != null &&
windowInputToken != null &&
width > 0 &&
height > 0 &&
windowVisibility == View.VISIBLE
) {
- stateListenerManager.currentUiSessionState = SandboxedSdkUiSessionState.Loading
- client = Client(this)
- adapter.openSession(
- context,
- windowInputToken!!,
- width,
- height,
- isZOrderOnTop,
- handler::post,
- client!!
- )
+ if (client == null && !isSecondary) {
+ stateListenerManager.currentUiSessionState = SandboxedSdkUiSessionState.Loading
+ client = Client(this)
+ adapter.openSession(
+ context,
+ windowInputToken!!,
+ width,
+ height,
+ isZOrderOnTop,
+ handler::post,
+ client!!
+ )
+ } else if (client != null && isSecondary) {
+ clientSecondary = Client(this)
+ this.refreshCallback = callback
+ adapter.openSession(
+ context,
+ windowInputToken!!,
+ width,
+ height,
+ isZOrderOnTop,
+ handler::post,
+ clientSecondary!!
+ )
+ }
}
}
@@ -491,8 +521,13 @@
super.removeView(surfaceView)
}
+ /**
+ * A SandboxedSdkView may have one active primary client and a secondary client with which a
+ * session is being formed. Once [Client.onSessionOpened] is received on the secondaryClient we
+ * close the session with the primary client and promote the secondary to the primary client.
+ */
internal class Client(private var sandboxedSdkView: SandboxedSdkView?) :
- SandboxedUiAdapter.SessionClient {
+ RefreshableSessionClient {
private var session: SandboxedUiAdapter.Session? = null
private var pendingWidth: Int? = null
@@ -543,6 +578,10 @@
return
}
val view = checkNotNull(sandboxedSdkView) { "SandboxedSdkView should not be null" }
+ if (this === view.clientSecondary) {
+ view.switchClient()
+ view.refreshCallback?.accept(true)
+ }
view.setContentView(session.view)
this.session = session
val width = pendingWidth
@@ -561,14 +600,33 @@
override fun onSessionError(throwable: Throwable) {
if (sandboxedSdkView == null) return
-
- sandboxedSdkView?.onClientClosedSession(throwable)
+ sandboxedSdkView?.let { view ->
+ if (this == view.clientSecondary) {
+ view.clientSecondary = null
+ view.refreshCallback?.accept(false)
+ } else {
+ view.onClientClosedSession(throwable)
+ }
+ }
}
override fun onResizeRequested(width: Int, height: Int) {
if (sandboxedSdkView == null) return
sandboxedSdkView?.requestResize(width, height)
}
+
+ override fun onSessionRefreshRequested(callback: Consumer<Boolean>) {
+ sandboxedSdkView?.checkClientOpenSession(true, callback)
+ }
+ }
+
+ private fun switchClient() {
+ if (this.clientSecondary == null) {
+ throw java.lang.IllegalStateException("secondary client must be non null for switch")
+ }
+ // close session with primary client
+ this.client?.close()
+ this.client = this.clientSecondary
}
internal class StateListenerManager {
diff --git a/privacysandbox/ui/ui-core/api/current.txt b/privacysandbox/ui/ui-core/api/current.txt
index aa33944..c7c6286 100644
--- a/privacysandbox/ui/ui-core/api/current.txt
+++ b/privacysandbox/ui/ui-core/api/current.txt
@@ -1,6 +1,21 @@
// Signature format: 4.0
package androidx.privacysandbox.ui.core {
+ public final class DelegatingSandboxedUiAdapter implements androidx.privacysandbox.ui.core.SandboxedUiAdapter {
+ ctor public DelegatingSandboxedUiAdapter(android.os.Bundle delegate);
+ method public void addDelegateChangeListener(androidx.privacysandbox.ui.core.DelegatingSandboxedUiAdapter.DelegateChangeListener listener);
+ method public void addObserverFactory(androidx.privacysandbox.ui.core.SessionObserverFactory sessionObserverFactory);
+ method public android.os.Bundle getDelegate();
+ method public void openSession(android.content.Context context, android.os.IBinder windowInputToken, int initialWidth, int initialHeight, boolean isZOrderOnTop, java.util.concurrent.Executor clientExecutor, androidx.privacysandbox.ui.core.SandboxedUiAdapter.SessionClient client);
+ method public void removeDelegateChangeListener(androidx.privacysandbox.ui.core.DelegatingSandboxedUiAdapter.DelegateChangeListener listener);
+ method public void removeObserverFactory(androidx.privacysandbox.ui.core.SessionObserverFactory sessionObserverFactory);
+ method public suspend Object? updateDelegate(android.os.Bundle delegate, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+ }
+
+ public static interface DelegatingSandboxedUiAdapter.DelegateChangeListener {
+ method public default suspend Object? onDelegateChanged(android.os.Bundle delegate, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+ }
+
public final class SandboxedSdkViewUiInfo {
ctor public SandboxedSdkViewUiInfo(int uiContainerWidth, int uiContainerHeight, android.graphics.Rect onScreenGeometry, float uiContainerOpacityHint);
method public static androidx.privacysandbox.ui.core.SandboxedSdkViewUiInfo fromBundle(android.os.Bundle bundle);
diff --git a/privacysandbox/ui/ui-core/api/restricted_current.txt b/privacysandbox/ui/ui-core/api/restricted_current.txt
index aa33944..c7c6286 100644
--- a/privacysandbox/ui/ui-core/api/restricted_current.txt
+++ b/privacysandbox/ui/ui-core/api/restricted_current.txt
@@ -1,6 +1,21 @@
// Signature format: 4.0
package androidx.privacysandbox.ui.core {
+ public final class DelegatingSandboxedUiAdapter implements androidx.privacysandbox.ui.core.SandboxedUiAdapter {
+ ctor public DelegatingSandboxedUiAdapter(android.os.Bundle delegate);
+ method public void addDelegateChangeListener(androidx.privacysandbox.ui.core.DelegatingSandboxedUiAdapter.DelegateChangeListener listener);
+ method public void addObserverFactory(androidx.privacysandbox.ui.core.SessionObserverFactory sessionObserverFactory);
+ method public android.os.Bundle getDelegate();
+ method public void openSession(android.content.Context context, android.os.IBinder windowInputToken, int initialWidth, int initialHeight, boolean isZOrderOnTop, java.util.concurrent.Executor clientExecutor, androidx.privacysandbox.ui.core.SandboxedUiAdapter.SessionClient client);
+ method public void removeDelegateChangeListener(androidx.privacysandbox.ui.core.DelegatingSandboxedUiAdapter.DelegateChangeListener listener);
+ method public void removeObserverFactory(androidx.privacysandbox.ui.core.SessionObserverFactory sessionObserverFactory);
+ method public suspend Object? updateDelegate(android.os.Bundle delegate, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+ }
+
+ public static interface DelegatingSandboxedUiAdapter.DelegateChangeListener {
+ method public default suspend Object? onDelegateChanged(android.os.Bundle delegate, kotlin.coroutines.Continuation<? super kotlin.Unit>);
+ }
+
public final class SandboxedSdkViewUiInfo {
ctor public SandboxedSdkViewUiInfo(int uiContainerWidth, int uiContainerHeight, android.graphics.Rect onScreenGeometry, float uiContainerOpacityHint);
method public static androidx.privacysandbox.ui.core.SandboxedSdkViewUiInfo fromBundle(android.os.Bundle bundle);
diff --git a/privacysandbox/ui/ui-core/build.gradle b/privacysandbox/ui/ui-core/build.gradle
index 7feb06c..a9ab12e 100644
--- a/privacysandbox/ui/ui-core/build.gradle
+++ b/privacysandbox/ui/ui-core/build.gradle
@@ -31,6 +31,7 @@
dependencies {
api(libs.kotlinStdlib)
+ implementation(libs.kotlinCoroutinesCore)
api("androidx.annotation:annotation:1.8.1")
androidTestImplementation(libs.junit)
androidTestImplementation(libs.kotlinStdlib)
diff --git a/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/IDelegateChangeListener.aidl b/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/IDelegateChangeListener.aidl
new file mode 100644
index 0000000..3ede24a
--- /dev/null
+++ b/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/IDelegateChangeListener.aidl
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.core;
+import androidx.privacysandbox.ui.core.IDelegatorCallback;
+
+@JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)")
+oneway interface IDelegateChangeListener {
+ void onDelegateChanged(in Bundle delegate, IDelegatorCallback callback);
+}
diff --git a/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/IDelegatingSandboxedUiAdapter.aidl b/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/IDelegatingSandboxedUiAdapter.aidl
new file mode 100644
index 0000000..beee8d5
--- /dev/null
+++ b/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/IDelegatingSandboxedUiAdapter.aidl
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.core;
+
+import androidx.privacysandbox.ui.core.IRemoteSessionClient;
+import androidx.privacysandbox.ui.core.ISandboxedUiAdapter;
+import androidx.privacysandbox.ui.core.IDelegateChangeListener;
+import android.content.Context;
+
+@JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)")
+ oneway interface IDelegatingSandboxedUiAdapter {
+ void addDelegateChangeListener(IDelegateChangeListener listener);
+ void removeDelegateChangeListener(IDelegateChangeListener listener);
+}
diff --git a/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/IDelegatorCallback.aidl b/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/IDelegatorCallback.aidl
new file mode 100644
index 0000000..5aabeeb
--- /dev/null
+++ b/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/IDelegatorCallback.aidl
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.core;
+
+@JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)")
+oneway interface IDelegatorCallback {
+ void onDelegateChangeResult(boolean success);
+}
diff --git a/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/ISessionRefreshCallback.aidl b/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/ISessionRefreshCallback.aidl
new file mode 100644
index 0000000..d81f6bf
--- /dev/null
+++ b/privacysandbox/ui/ui-core/src/main/aidl/androidx/privacysandbox/ui/core/ISessionRefreshCallback.aidl
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.core;
+
+@JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)")
+oneway interface ISessionRefreshCallback {
+ void onRefreshResult(boolean success);
+}
diff --git a/privacysandbox/ui/ui-core/src/main/java/androidx/privacysandbox/ui/core/DelegatingSandboxedUiAdapter.kt b/privacysandbox/ui/ui-core/src/main/java/androidx/privacysandbox/ui/core/DelegatingSandboxedUiAdapter.kt
new file mode 100644
index 0000000..b959d30
--- /dev/null
+++ b/privacysandbox/ui/ui-core/src/main/java/androidx/privacysandbox/ui/core/DelegatingSandboxedUiAdapter.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.privacysandbox.ui.core
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Bundle
+import android.os.IBinder
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.Executor
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/**
+ * A [SandboxedUiAdapter] that helps delegate calls to other uiAdapters.
+ *
+ * When this adapter is set to the client's container, the adapter can switch the delegate
+ * uiAdapter, that serves the `Session`, without involving the client. For each new delegate, a
+ * session would be requested with all clients of this adapter.
+ *
+ * One example use-case of these kind of UIAdapters is to support updating the provider of the UI
+ * without the client's involvement.
+ */
+class DelegatingSandboxedUiAdapter(private var delegate: Bundle) : SandboxedUiAdapter {
+
+ /** Listener that consumes events to process the delegate change for a client */
+ interface DelegateChangeListener {
+ /** When invoked triggers processing of the delegate change for a client */
+ suspend fun onDelegateChanged(delegate: Bundle) {}
+ }
+
+ /**
+ * List of listeners that consume events to refresh the currently active sessions of this
+ * adapter.
+ */
+ private var delegateChangeListeners: CopyOnWriteArrayList<DelegateChangeListener> =
+ CopyOnWriteArrayList()
+ private val mutex = Mutex()
+
+ /**
+ * Updates the delegate and notifies all listeners to process the update. If any listener fails
+ * process refresh we throw [IllegalStateException]. Cancellation of this API does not propagate
+ * to the client side.
+ */
+ suspend fun updateDelegate(delegate: Bundle) {
+ // TODO(b/374955412): Support cancellation across process
+ mutex.withLock {
+ this.delegate = delegate
+ coroutineScope {
+ delegateChangeListeners.forEach { l -> launch { l.onDelegateChanged(delegate) } }
+ }
+ }
+ }
+
+ override fun openSession(
+ context: Context,
+ windowInputToken: IBinder,
+ initialWidth: Int,
+ initialHeight: Int,
+ isZOrderOnTop: Boolean,
+ clientExecutor: Executor,
+ client: SandboxedUiAdapter.SessionClient,
+ ) {}
+
+ @SuppressLint("ExecutorRegistration")
+ // Used by [updateDelegate] and therefore runs on updateDelegate's calling context
+ fun addDelegateChangeListener(listener: DelegateChangeListener) {
+ delegateChangeListeners.add(listener)
+ }
+
+ // TODO(b/350656753): Add tests to check functionality of DelegatingAdapters
+ @SuppressLint("ExecutorRegistration")
+ // Used by [updateDelegate] and therefore runs on updateDelegate's calling context
+ fun removeDelegateChangeListener(listener: DelegateChangeListener) {
+ delegateChangeListeners.remove(listener)
+ }
+
+ override fun addObserverFactory(sessionObserverFactory: SessionObserverFactory) {}
+
+ override fun removeObserverFactory(sessionObserverFactory: SessionObserverFactory) {}
+
+ /** Fetches the current delegate which is a [SandboxedUiAdapter] Bundle. */
+ // TODO(b/375388971): Check coreLibInfo is present
+ fun getDelegate(): Bundle {
+ return delegate
+ }
+}
diff --git a/privacysandbox/ui/ui-core/src/main/java/androidx/privacysandbox/ui/core/ProtocolConstants.kt b/privacysandbox/ui/ui-core/src/main/java/androidx/privacysandbox/ui/core/ProtocolConstants.kt
index 8f480b4..7c61864 100644
--- a/privacysandbox/ui/ui-core/src/main/java/androidx/privacysandbox/ui/core/ProtocolConstants.kt
+++ b/privacysandbox/ui/ui-core/src/main/java/androidx/privacysandbox/ui/core/ProtocolConstants.kt
@@ -26,4 +26,5 @@
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
object ProtocolConstants {
const val sdkActivityLauncherBinderKey = "sdkActivityLauncherBinderKey"
+ const val delegateKey = "delegate"
}
diff --git a/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt
index fff6cad6..30042fc 100644
--- a/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt
+++ b/privacysandbox/ui/ui-provider/src/main/java/androidx/privacysandbox/ui/provider/BinderAdapterDelegate.kt
@@ -31,29 +31,80 @@
import android.view.View
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
+import androidx.privacysandbox.ui.core.DelegatingSandboxedUiAdapter
+import androidx.privacysandbox.ui.core.IDelegateChangeListener
+import androidx.privacysandbox.ui.core.IDelegatingSandboxedUiAdapter
+import androidx.privacysandbox.ui.core.IDelegatorCallback
import androidx.privacysandbox.ui.core.IRemoteSessionClient
import androidx.privacysandbox.ui.core.IRemoteSessionController
import androidx.privacysandbox.ui.core.ISandboxedUiAdapter
+import androidx.privacysandbox.ui.core.ProtocolConstants
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import androidx.privacysandbox.ui.core.SessionObserver
import androidx.privacysandbox.ui.core.SessionObserverContext
import androidx.privacysandbox.ui.core.SessionObserverFactory
import androidx.privacysandbox.ui.provider.impl.DeferredSessionClient
import java.util.concurrent.Executor
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.suspendCancellableCoroutine
/**
* Provides a [Bundle] containing a Binder which represents a [SandboxedUiAdapter]. The Bundle is
* shuttled to the host app in order for the [SandboxedUiAdapter] to be used to retrieve content.
*/
fun SandboxedUiAdapter.toCoreLibInfo(@Suppress("ContextFirst") context: Context): Bundle {
- val binderAdapter = BinderAdapterDelegate(context, this)
// TODO: Add version info
val bundle = Bundle()
+ val binderAdapter =
+ if (this is DelegatingSandboxedUiAdapter) {
+ bundle.putBundle(ProtocolConstants.delegateKey, this.getDelegate())
+ BinderDelegatingAdapter(this)
+ } else {
+ BinderAdapterDelegate(context, this)
+ }
// Bundle key is a binary compatibility requirement
+ // TODO(b/375389719): Move key to ProtocolConstants
bundle.putBinder("uiAdapterBinder", binderAdapter)
return bundle
}
+private class BinderDelegatingAdapter(private var adapter: DelegatingSandboxedUiAdapter) :
+ IDelegatingSandboxedUiAdapter.Stub() {
+ private class RemoteDelegateChangeListener(val binder: IDelegateChangeListener) :
+ DelegatingSandboxedUiAdapter.DelegateChangeListener {
+
+ override suspend fun onDelegateChanged(delegate: Bundle) {
+ suspendCancellableCoroutine { continuation ->
+ binder.onDelegateChanged(
+ delegate,
+ object : IDelegatorCallback.Stub() {
+ override fun onDelegateChangeResult(success: Boolean) {
+ if (success) {
+ continuation.resume(Unit)
+ } else {
+ continuation.resumeWithException(
+ IllegalStateException("Client failed to switch")
+ )
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+
+ override fun addDelegateChangeListener(binder: IDelegateChangeListener) {
+ val listener = RemoteDelegateChangeListener(binder)
+ adapter.addDelegateChangeListener(listener)
+ binder.asBinder().linkToDeath({ adapter.removeDelegateChangeListener(listener) }, 0)
+ }
+
+ override fun removeDelegateChangeListener(listener: IDelegateChangeListener) {
+ adapter.removeDelegateChangeListener(RemoteDelegateChangeListener(listener))
+ }
+}
+
private class BinderAdapterDelegate(
private val sandboxContext: Context,
private val adapter: SandboxedUiAdapter