SandboxedSdkView.kt

/*
 * 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.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.annotation.RequiresApi
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.
     */
    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) {

    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
    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.
     */
    fun setZOrderOnTopAndEnableUserInteraction(setOnTop: Boolean) {
        if (setOnTop == isZOrderOnTop) return
        client?.notifyZOrderChanged(setOnTop)
        isZOrderOnTop = setOnTop
        checkClientOpenSession()
    }

    private fun checkClientOpenSession() {
        val adapter = adapter
        if (client == null && adapter != null && isAttachedToWindow && width > 0 && height > 0) {
            stateListenerManager.currentUiSessionState = SandboxedSdkUiSessionState.Loading
            client = Client(this)
            adapter.openSession(
                context,
                width,
                height,
                isZOrderOnTop,
                handler::post,
                client!!
            )
        }
    }

    internal fun requestSize(width: Int, height: Int) {
        if (width == this.width && height == this.height) return
        requestedWidth = width
        requestedHeight = height
        requestLayout()
    }

    internal fun removeContentView() {
        if (childCount == 1) {
            super.removeViewAt(0)
        }
    }

    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)
        }
        stateListenerManager.currentUiSessionState = SandboxedSdkUiSessionState.Active
    }

    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) {
        getChildAt(0)?.layout(left, top, right, bottom)
        checkClientOpenSession()
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        checkClientOpenSession()
    }

    override fun onDetachedFromWindow() {
        client?.close()
        client = null
        super.onDetachedFromWindow()
    }

    override fun onSizeChanged(
        width: Int,
        height: Int,
        oldWidth: Int,
        oldHeight: Int
    ) {
        super.onSizeChanged(width, height, oldWidth, oldHeight)
        client?.notifyResized(width, height)
        checkClientOpenSession()
    }

    // TODO(b/270971893) Compare to old configuration before notifying of configuration change.
    override fun onConfigurationChanged(config: Configuration?) {
        requireNotNull(config) { "Config cannot be null" }
        super.onConfigurationChanged(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)
        }
    }
}