/*
* Copyright 2023 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.view
import android.content.Context
import android.content.res.Configuration
import android.graphics.Rect
import android.os.Build
import android.os.IBinder
import android.util.AttributeSet
import android.view.SurfaceControl
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import android.view.ViewTreeObserver
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionState.Active
import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionState.Idle
import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionState.Loading
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.min
/**
* A listener for changes to the state of the UI session associated with SandboxedSdkView.
*/
fun interface SandboxedSdkUiSessionStateChangedListener {
/**
* Called when the state of the session for SandboxedSdkView is updated.
*/
fun onStateChanged(state: SandboxedSdkUiSessionState)
}
/**
* Represents the state of a UI session.
*
* A UI session refers to the session opened with a [SandboxedUiAdapter] to let the host display UI
* from the UI provider. If the host has requested to open a session with its [SandboxedUiAdapter],
* the state will be [Loading] until the session has been opened and the content has been displayed.
* At this point, the state will become [Active]. If there is no active session and no session is
* being loaded, the state is [Idle].
*/
sealed class SandboxedSdkUiSessionState private constructor() {
/**
* A UI session is currently attempting to be opened.
*
* This state occurs when the UI has requested to open a session with its [SandboxedUiAdapter].
* No UI from the [SandboxedUiAdapter] will be shown during this state. When the session has
* been successfully opened and the content has been displayed, the state will transition to
* [Active].
*/
object Loading : SandboxedSdkUiSessionState()
/**
* There is an open session with the supplied [SandboxedUiAdapter] and its UI is currently
* being displayed. This state is set after the first draw event of the [SandboxedSdkView].
*/
object Active : SandboxedSdkUiSessionState()
/**
* There is no currently open UI session and there is no operation in progress to open one.
*
* The UI provider may close the session at any point, which will result in the state becoming
* [Idle] if the session is closed without an error. If there is an error that causes the
* session to close, the state will be [Error].
*
* If a new [SandboxedUiAdapter] is set on a [SandboxedSdkView], the existing session will close
* and the state will become [Idle].
*/
object Idle : SandboxedSdkUiSessionState()
/**
* There was an error in the UI session.
*
* @param throwable The error that caused the session to end.
*/
class Error(val throwable: Throwable) : SandboxedSdkUiSessionState()
}
// TODO(b/268014171): Remove API requirements once S- support is added
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
class SandboxedSdkView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
ViewGroup(context, attrs) {
// TODO(b/284147223): Remove this logic in V+
private val surfaceView = SurfaceView(context).apply {
visibility = GONE
}
// This will only be invoked when the content view has been set and the window is attached.
private val surfaceChangedCallback = object : SurfaceHolder.Callback {
override fun surfaceCreated(p0: SurfaceHolder) {
setClippingBounds(true)
viewTreeObserver.addOnGlobalLayoutListener(globalLayoutChangeListener)
}
override fun surfaceChanged(p0: SurfaceHolder, p1: Int, p2: Int, p3: Int) {
}
override fun surfaceDestroyed(p0: SurfaceHolder) {
}
}
// This will only be invoked when the content view has been set and the window is attached.
private val globalLayoutChangeListener =
ViewTreeObserver.OnGlobalLayoutListener { setClippingBounds() }
private var adapter: SandboxedUiAdapter? = null
private var client: Client? = null
private var isZOrderOnTop = true
private var contentView: View? = null
private var requestedWidth = -1
private var requestedHeight = -1
private var isTransitionGroupSet = false
private var windowInputToken: IBinder? = null
private var currentClippingBounds = Rect()
private var currentConfig = context.resources.configuration
internal val stateListenerManager: StateListenerManager = StateListenerManager()
/**
* Adds a state change listener to the UI session and immediately reports the current
* state.
*/
fun addStateChangedListener(stateChangedListener: SandboxedSdkUiSessionStateChangedListener) {
stateListenerManager.addStateChangedListener(stateChangedListener)
}
/**
* Removes the specified state change listener from SandboxedSdkView.
*/
fun removeStateChangedListener(
stateChangedListener: SandboxedSdkUiSessionStateChangedListener
) {
stateListenerManager.removeStateChangedListener(stateChangedListener)
}
fun setAdapter(sandboxedUiAdapter: SandboxedUiAdapter) {
if (this.adapter === sandboxedUiAdapter) return
client?.close()
client = null
this.adapter = sandboxedUiAdapter
checkClientOpenSession()
}
/**
* Sets the Z-ordering of the [SandboxedSdkView]'s surface, relative to its window.
*
* When [providerUiOnTop] is true, every [android.view.MotionEvent] on the [SandboxedSdkView]
* will be sent to the UI provider. When [providerUiOnTop] is false, every
* [android.view.MotionEvent] will be sent to the client. By default, motion events are sent to
* the UI provider.
*/
fun orderProviderUiAboveClientUi(providerUiOnTop: Boolean) {
if (providerUiOnTop == isZOrderOnTop) return
client?.notifyZOrderChanged(providerUiOnTop)
isZOrderOnTop = providerUiOnTop
checkClientOpenSession()
}
internal fun setClippingBounds(forceUpdate: Boolean = false) {
checkNotNull(contentView)
check(isAttachedToWindow)
val updateRequired = getBoundingParent(currentClippingBounds) || forceUpdate
if (!updateRequired) {
return
}
val sv: SurfaceView = contentView as SurfaceView
val attachedSurfaceControl = checkNotNull(sv.rootSurfaceControl) {
"attachedSurfaceControl should be non-null if the window is attached"
}
val name = "clippingBounds-${System.currentTimeMillis()}"
val clippingBoundsSurfaceControl =
SurfaceControl.Builder().setName(name)
.build()
val reparentSurfaceControlTransaction = SurfaceControl.Transaction()
.reparent(sv.surfaceControl, clippingBoundsSurfaceControl)
val reparentClippingBoundsTransaction =
checkNotNull(
attachedSurfaceControl.buildReparentTransaction(clippingBoundsSurfaceControl)) {
"Reparent transaction should be non-null if the window is attached"
}
reparentClippingBoundsTransaction.setCrop(
clippingBoundsSurfaceControl, currentClippingBounds)
reparentClippingBoundsTransaction.setVisibility(
clippingBoundsSurfaceControl, true)
reparentSurfaceControlTransaction.merge(reparentClippingBoundsTransaction)
attachedSurfaceControl.applyTransactionOnDraw(reparentSurfaceControlTransaction)
}
/**
* Computes the window space coordinates for the bounding parent of this view, and stores the
* result in [rect].
*
* Returns true if the coordinates have changed, false otherwise.
*/
@VisibleForTesting
internal fun getBoundingParent(rect: Rect): Boolean {
val prevBounds = Rect(rect)
var viewParent: ViewParent? = parent
while (viewParent != null && viewParent is View) {
val v = viewParent as View
if (v.isScrollContainer || v.id == android.R.id.content) {
v.getGlobalVisibleRect(rect)
return prevBounds != rect
}
viewParent = viewParent.getParent()
}
return false
}
private fun checkClientOpenSession() {
val adapter = adapter
if (client == null && adapter != null && windowInputToken != null &&
width > 0 && height > 0) {
stateListenerManager.currentUiSessionState = SandboxedSdkUiSessionState.Loading
client = Client(this)
adapter.openSession(
context,
windowInputToken!!,
width,
height,
isZOrderOnTop,
handler::post,
client!!
)
}
}
/**
* Attaches a temporary [SurfaceView] to the view hierarchy. This [SurfaceView] will be removed
* once it has been attached to the window and its host token is non-null.
*
* TODO(b/284147223): Remove this logic in V+
*/
private fun attachTemporarySurfaceView() {
val onSurfaceViewAttachedListener =
object : OnAttachStateChangeListener {
override fun onViewAttachedToWindow(view: View) {
view.removeOnAttachStateChangeListener(this)
removeSurfaceViewAndOpenSession()
}
override fun onViewDetachedFromWindow(view: View) {
}
}
surfaceView.addOnAttachStateChangeListener(onSurfaceViewAttachedListener)
super.addView(surfaceView, 0, generateDefaultLayoutParams())
}
internal fun removeSurfaceViewAndOpenSession() {
windowInputToken = surfaceView.hostToken
super.removeView(surfaceView)
checkClientOpenSession()
}
internal fun requestSize(width: Int, height: Int) {
if (width == this.width && height == this.height) return
requestedWidth = width
requestedHeight = height
requestLayout()
}
private fun removeContentView() {
removeCallbacks()
if (childCount == 1) {
super.removeViewAt(0)
}
}
private fun removeCallbacks() {
(contentView as? SurfaceView)?.holder?.removeCallback(surfaceChangedCallback)
viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutChangeListener)
}
internal fun setContentView(contentView: View) {
if (childCount > 1) {
throw IllegalStateException("Number of children views must not exceed 1")
}
this.contentView = contentView
removeContentView()
if (contentView.layoutParams == null) {
super.addView(contentView, 0, generateDefaultLayoutParams())
} else {
super.addView(contentView, 0, contentView.layoutParams)
}
// Wait for the next frame commit before sending an ACTIVE state change to listeners.
viewTreeObserver.registerFrameCommitCallback {
stateListenerManager.currentUiSessionState =
SandboxedSdkUiSessionState.Active
}
if (contentView is SurfaceView) {
contentView.holder.addCallback(surfaceChangedCallback)
}
}
internal fun onClientClosedSession(error: Throwable? = null) {
removeContentView()
stateListenerManager.currentUiSessionState = if (error != null) {
SandboxedSdkUiSessionState.Error(error)
} else {
SandboxedSdkUiSessionState.Idle
}
}
private fun calculateMeasuredDimension(requestedSize: Int, measureSpec: Int): Int {
val measureSpecSize = MeasureSpec.getSize(measureSpec)
when (MeasureSpec.getMode(measureSpec)) {
MeasureSpec.EXACTLY -> {
return measureSpecSize
}
MeasureSpec.UNSPECIFIED -> {
return if (requestedSize < 0) {
measureSpecSize
} else {
requestedSize
}
}
MeasureSpec.AT_MOST -> {
return if (requestedSize >= 0) {
min(requestedSize, measureSpecSize)
} else {
measureSpecSize
}
}
else -> {
return measureSpecSize
}
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val newWidth = calculateMeasuredDimension(requestedWidth, widthMeasureSpec)
val newHeight = calculateMeasuredDimension(requestedHeight, heightMeasureSpec)
setMeasuredDimension(newWidth, newHeight)
}
override fun isTransitionGroup(): Boolean = !isTransitionGroupSet || super.isTransitionGroup()
override fun setTransitionGroup(isTransitionGroup: Boolean) {
super.setTransitionGroup(isTransitionGroup)
isTransitionGroupSet = true
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
// Child needs to receive coordinates that are relative to the parent.
getChildAt(0)?.layout(
/* l = */ 0,
/* t = */ 0,
/* r = */ right - left,
/* b = */ bottom - top)
checkClientOpenSession()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
attachTemporarySurfaceView()
}
override fun onDetachedFromWindow() {
client?.close()
client = null
windowInputToken = null
removeCallbacks()
super.onDetachedFromWindow()
}
override fun onSizeChanged(
width: Int,
height: Int,
oldWidth: Int,
oldHeight: Int
) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
client?.notifyResized(width, height)
checkClientOpenSession()
}
override fun onConfigurationChanged(config: Configuration?) {
requireNotNull(config) { "Config cannot be null" }
if (config == currentConfig) {
return
}
super.onConfigurationChanged(config)
currentConfig = config
client?.notifyConfigurationChanged(config)
checkClientOpenSession()
}
/**
* @throws UnsupportedOperationException when called
*/
override fun addView(
view: View?,
index: Int,
params: LayoutParams?
) {
throw UnsupportedOperationException("Cannot add a view to SandboxedSdkView")
}
/**
* @throws UnsupportedOperationException when called
*/
override fun removeView(view: View?) {
throw UnsupportedOperationException("Cannot remove a view from SandboxedSdkView")
}
/**
* @throws UnsupportedOperationException when called
*/
override fun removeViewInLayout(view: View?) {
throw UnsupportedOperationException("Cannot remove a view from SandboxedSdkView")
}
/**
* @throws UnsupportedOperationException when called
*/
override fun removeViewsInLayout(start: Int, count: Int) {
throw UnsupportedOperationException("Cannot remove a view from SandboxedSdkView")
}
/**
* @throws UnsupportedOperationException when called
*/
override fun removeViewAt(index: Int) {
throw UnsupportedOperationException("Cannot remove a view from SandboxedSdkView")
}
/**
* @throws UnsupportedOperationException when called
*/
override fun removeViews(start: Int, count: Int) {
throw UnsupportedOperationException("Cannot remove a view from SandboxedSdkView")
}
/**
* @throws UnsupportedOperationException when called
*/
override fun removeAllViews() {
throw UnsupportedOperationException("Cannot remove a view from SandboxedSdkView")
}
/**
* @throws UnsupportedOperationException when called
*/
override fun removeAllViewsInLayout() {
throw UnsupportedOperationException("Cannot remove a view from SandboxedSdkView")
}
internal class Client(private var sandboxedSdkView: SandboxedSdkView?) :
SandboxedUiAdapter.SessionClient {
private var session: SandboxedUiAdapter.Session? = null
private var pendingWidth: Int? = null
private var pendingHeight: Int? = null
private var pendingZOrderOnTop: Boolean? = null
private var pendingConfiguration: Configuration? = null
fun notifyConfigurationChanged(configuration: Configuration) {
val session = session
if (session != null) {
session.notifyConfigurationChanged(configuration)
} else {
pendingConfiguration = configuration
}
}
fun notifyResized(width: Int, height: Int) {
val session = session
if (session != null) {
session.notifyResized(width, height)
} else {
pendingWidth = width
pendingHeight = height
}
}
fun notifyZOrderChanged(isZOrderOnTop: Boolean) {
if (sandboxedSdkView?.isZOrderOnTop == isZOrderOnTop) return
val session = session
if (session != null) {
session.notifyZOrderChanged(isZOrderOnTop)
} else {
pendingZOrderOnTop = isZOrderOnTop
}
}
fun close() {
session?.close()
session = null
sandboxedSdkView?.onClientClosedSession()
sandboxedSdkView = null
}
override fun onSessionOpened(session: SandboxedUiAdapter.Session) {
if (sandboxedSdkView == null) {
session.close()
return
}
sandboxedSdkView?.setContentView(session.view)
this.session = session
val width = pendingWidth
val height = pendingHeight
if ((width != null) && (height != null) && (width >= 0) && (height >= 0)) {
session.notifyResized(width, height)
}
pendingConfiguration?.let {
session.notifyConfigurationChanged(it)
}
pendingConfiguration = null
pendingZOrderOnTop?.let {
session.notifyZOrderChanged(it)
}
pendingZOrderOnTop = null
}
override fun onSessionError(throwable: Throwable) {
if (sandboxedSdkView == null) return
sandboxedSdkView?.onClientClosedSession(throwable)
}
override fun onResizeRequested(width: Int, height: Int) {
if (sandboxedSdkView == null) return
sandboxedSdkView?.requestSize(width, height)
}
}
internal class StateListenerManager {
internal var currentUiSessionState: SandboxedSdkUiSessionState =
SandboxedSdkUiSessionState.Idle
set(value) {
if (field != value) {
field = value
for (listener in stateChangedListeners) {
listener.onStateChanged(currentUiSessionState)
}
}
}
private var stateChangedListeners =
CopyOnWriteArrayList<SandboxedSdkUiSessionStateChangedListener>()
fun addStateChangedListener(listener: SandboxedSdkUiSessionStateChangedListener) {
stateChangedListeners.add(listener)
listener.onStateChanged(currentUiSessionState)
}
fun removeStateChangedListener(listener: SandboxedSdkUiSessionStateChangedListener) {
stateChangedListeners.remove(listener)
}
}
}