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