VoipParticipantExtensionManager.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.core.telecom.extensions.voip

import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.RestrictTo
import androidx.core.telecom.CallControlScope
import androidx.core.telecom.CallsManager
import androidx.core.telecom.extensions.Capability
import androidx.core.telecom.extensions.IParticipantStateListener
import androidx.core.telecom.extensions.Participant
import androidx.core.telecom.extensions.voip.VoipExtensionManager.Companion.isActionSupportedByIcs
import androidx.core.telecom.internal.CallChannels
import androidx.core.telecom.internal.utils.CapabilityExchangeUtils
import androidx.core.telecom.internal.utils.CapabilityExchangeUtils.Companion.preprocessSupportedActions
import androidx.core.telecom.util.ExperimentalAppActions
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch

/**
 * VOIP side manager for handling the Participant extension. The manager is set up if the VOIP app
 * supports the extension and allows ICS to subscribe to these updates. Internally, the VOIP app
 * does version handling to ensure backwards compatibility.
 */
@ExperimentalAppActions
@RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)
@RequiresApi(Build.VERSION_CODES.O)
internal class VoipParticipantExtensionManager(
    private val session: CallControlScope,
    private val coroutineContext: CoroutineContext,
    private val callChannels: CallChannels,
    voipCapability: Capability
) {
    // List of actions supported by the VOIP app (sanitized for potential user input error).
    private val voipSupportedActions: Set<@CallsManager.Companion.ExtensionSupportedActions Int> =
        preprocessSupportedActions(
            voipCapability.featureId, voipCapability.supportedActions)

    // Keep track of ICS subscribers. This contains the listener which the VOIP app can use to send
    // updates to the ICS, the negotiated actions between the ICS and VOIP app, as well as the
    // version, to handle backwards compatibility.
    private val activeSubscribers: MutableMap<Int, Triple<IParticipantStateListener,
        IntArray, Int>> = HashMap()

    // Singleton that references the delegates that will be used to support the kotlin extensions
    // being added into CallControlScope for supporting capabilities.
    private val extensionSingleton = CallControlScopeExtensionSingleton.getInstance()

    // Jobs that are run to handle updates to the participants state. The lifecycle needs to be
    // managed explicitly so that these jobs are cancelled when the call session is terminated.
    internal lateinit var participantUpdateJob: Job
    internal lateinit var activeParticipantsJob: Job
    internal lateinit var raisedHandParticipantsJob: Job
    internal lateinit var callbackActionsJob: Job

    companion object {
        private val TAG = VoipParticipantExtensionManager::class.simpleName

        /**
         * Convert list of participants into a list of participant ids.
         */
        internal fun resolveIdsFromParticipants(participants: Set<Participant>): IntArray {
            val participantIds = IntArray(participants.size)
            participants.forEachIndexed { index, participant ->
                participantIds[index] = participant.id
            }
            return participantIds
        }
    }

    init {
        // Set up MutableFlowStates with initial values so that it can be accessed by the VOIP app
        // via CallControlScope and be used as the source of truth for representing the current
        // Participant state.
        registerParticipantExtension()
        startHandlingUpdates()
    }

    /***********************************************************************************************
     *                           Internal Functions
     *********************************************************************************************/

    /**
     * Subscribe ICS side for updates. This is done when the VOIP app is notified that the ICS
     * supports Participant extensions via
     * [CapabilityExchangeListener.onCreateParticipantExtension], which passes along the listener
     * that the VOIP app can use to send updates to the ICS as well as the negotiated actions
     * resolved from the ICS side.
     *
     * @param icsId id for the ICS that is subscribing to updates.
     * @param participantStateListener listener provided by the ICS that the VOIP uses to send
     *                                 participant state updates.
     * @param icsSupportedActions actions supported by the ICS.
     * @param version supported by the ICS.
     */
    internal fun subscribeToVoipUpdates(
        icsId: Int,
        participantStateListener: IParticipantStateListener,
        icsSupportedActions: IntArray,
        version: Int
    ) {
        Log.i(TAG, "Subscribing ICS $icsId to receive participant extension updates.")
        activeSubscribers[icsId] = Triple(participantStateListener, icsSupportedActions, version)
        var currentActiveParticipantId = CapabilityExchangeUtils.NULL_PARTICIPANT_ID
        session.activeParticipant!!.value?.let {
            currentActiveParticipantId = it.id
        }

        // Todo: Version handling
        // Send initial states via ParticipantStateListener. Flows should already be instantiated
        // at this point.
        try {
            participantStateListener.updateParticipants(session.participants!!.value.toTypedArray())
            participantStateListener.updateActiveParticipant(currentActiveParticipantId)
            participantStateListener.updateRaisedHandsAction(
                resolveIdsFromParticipants(session.raisedHandParticipants!!.value)
            )

            // Notify the ICS that sync has been completed, providing a callback that it can
            // invoke actions on.
            participantStateListener.finishSync(
                VoipParticipantActions(
                    session, callChannels, voipSupportedActions
                )
            )
        } catch (e: Exception) {
            CapabilityExchangeUtils.handleVoipSideUpdateExceptions(TAG!!, "subscribeToVoipUpdates",
                CapabilityExchangeUtils.PARTICIPANT_TAG, e)
            // If an exception was thrown before or during finishSync(), there's no reason to
            // consider the ICS as an active subscriber.
            unsubscribeFromUpdates(icsId)
        }
    }

    /**
     * Unsubscribes the ICS side from receiving updates around call detail extensions. This would
     * be invoked when the ICS signals the VOIP side via
     * [CapabilityExchangeListener.onRemoveExtensions].
     *
     * @param icsId indicating which ICS to unsubscribe.
     */
    internal fun unsubscribeFromUpdates(icsId: Int): Boolean {
        Log.i(TAG, "Unsubscribing ICS $icsId from receiving participant extension updates.");
        return activeSubscribers.remove(icsId) != null
    }

    /**
     * Tear down manager to stop providing updates and clear delegate mapping.
     */
    internal fun tearDown() {
        Log.i(TAG, "Tearing down participants extension.");
        // Cancel jobs providing updates.
        cancelJobs()
        // Remove delegate mapping to CallControlScope extensions defined for this session.
        extensionSingleton.PARTICIPANT_DELEGATE.remove(session.getCallId())
        // Remove delegate mapping to extensions for this session.
        activeSubscribers.clear()
        // Close channel that receives Participant action requests from the ICS side.
        callChannels.voipParticipantActionRequestsChannel.close()
    }

    /***********************************************************************************************
     *                           Private Helpers
     *********************************************************************************************/

    /**
     * Register participant extension support on the VOIP side. This sets up the flows with the
     * initial empty states.
     *
     * Note: The CallControlScope extension properties would be undefined until this function is
     * invoked.
     */
    private fun registerParticipantExtension() {
        Log.i(TAG, "Registering participant extension for VOIP app.")
        // Create initial states and set up flows on the VOIP side for tracking updates.
        extensionSingleton.PARTICIPANT_DELEGATE[session.getCallId()] = ParticipantApiDelegate(
            MutableStateFlow(setOf()), MutableStateFlow(null), MutableStateFlow(setOf()))
    }

    /**
     * Begin setting up coroutines to handle updates for the participant state as well as the
     * incoming requests from the ICS to perform an action.
     */
    private fun startHandlingUpdates() {
        // Handle participants state updates and propagate to active ICS subscribers.
        processStateUpdates()
        // List of requests to be processed for invoking actions from ICS side.
        processActionRequests()
    }

    /**
     * Sets up CallControlScope extensions to collect updates from the specified flows so that the
     * VOIP side can propagate the updates to the ICS side.
     */
    private fun processStateUpdates() {
        participantUpdateJob = CoroutineScope(coroutineContext).launch {
            // For each ICS, send update via respective channels.
            session.participants?.collect {
                for ((icsId, subscriber) in activeSubscribers.entries) {
                    Log.i(TAG, "Updating participants state for ICS $icsId.")
                    val participantStateListener = subscriber.first
                    try {
                        participantStateListener.updateParticipants(it.toTypedArray())
                    } catch (e: Exception) {
                        CapabilityExchangeUtils.handleVoipSideUpdateExceptions(TAG!!,
                            "participantsUpdate", CapabilityExchangeUtils.PARTICIPANT_TAG, e)
                    }
                }
            }
        }

        activeParticipantsJob = CoroutineScope(coroutineContext).launch {
            // For each ICS, send update via respective channels.
            session.activeParticipant?.collect { participant ->
                var participantId = CapabilityExchangeUtils.NULL_PARTICIPANT_ID
                participant?.let {
                    participantId = it.id
                }
                // Send updates to all active ICS subscribers.
                for ((icsId, subscriber) in activeSubscribers.entries) {
                    Log.i(TAG, "Updating active participant state for ICS $icsId.")
                    val participantStateListener = subscriber.first
                    try {
                        participantStateListener.updateActiveParticipant(participantId)
                    } catch (e: Exception) {
                        CapabilityExchangeUtils.handleVoipSideUpdateExceptions(TAG!!,
                            "activeParticipantsUpdate", CapabilityExchangeUtils.PARTICIPANT_TAG, e)
                    }
                }
            }
        }

        raisedHandParticipantsJob = CoroutineScope(coroutineContext).launch {
            // For each ICS, send update via respective channels. If the action isn't supported by
            // the VOIP app, the flow will be null and no updates will be sent to the ICS side.
            session.raisedHandParticipants?.collect {
                val participantIds: IntArray = resolveIdsFromParticipants(it)
                for ((icsId, subscriber) in activeSubscribers.entries) {
                    val icsSupportedActions = subscriber.second
                    // Only send updates to ICS that support the raise hand action.
                    if (isActionSupportedByIcs(icsSupportedActions,
                            CallsManager.RAISE_HAND_ACTION)) {
                        Log.i(TAG, "Updating raise hand state for ICS $icsId.")
                        val participantStateListener = subscriber.first
                        try {
                            participantStateListener.updateRaisedHandsAction(participantIds)
                        } catch (e: Exception) {
                            CapabilityExchangeUtils.handleVoipSideUpdateExceptions(TAG!!,
                                "raisedHandsUpdate", CapabilityExchangeUtils.PARTICIPANT_TAG, e)
                        }
                    }
                }
            }
        }
    }

    /**
     * The requests for the callbacks are queued to the voipParticipantActionRequestsChannel to be
     * processed sequentially by the VOIP side.
     */
    private fun processActionRequests() {
        callbackActionsJob = CoroutineScope(coroutineContext).launch {
            callChannels.voipParticipantActionRequestsChannel.consumeEach {
                it.processAction()
            }
        }
    }

    /**
     * Manages the lifecycle of the launched coroutines to ensure that they are properly tore down
     * when the call session is terminated.
     */
    private fun cancelJobs() {
        participantUpdateJob.cancel()
        activeParticipantsJob.cancel()
        raisedHandParticipantsJob.cancel()
        callbackActionsJob.cancel()
    }

    /***********************************************************************************************
     *                          Internal Class Helpers
     *********************************************************************************************/

    /**
     * Encapsulates the participant extension states for V1 (participants, activeParticipant, and
     * which participants have their hand raised).
     */
    internal class ParticipantApiDelegate(
        internal val participantsFlow: MutableStateFlow<Set<Participant>>,
        internal val activeParticipantFlow: MutableStateFlow<Participant?>,
        internal val raisedHandParticipantsFlow: MutableStateFlow<Set<Participant>>
    )
}

/***********************************************************************************************
 *                          CallControlScope Extensions
 *********************************************************************************************/

/**
 * Extension properties/ functions to be supported for voip actions. Extension properties cannot
 * be translated to backing fields so, internally, we need to resolve the APIs for each
 * CallControlScope using a delegate to mimic this behavior.
 */
internal val CallControlScope.activeParticipant: MutableStateFlow<Participant?>?
    @ExperimentalAppActions
    @RequiresApi(Build.VERSION_CODES.O)
    get() = CallControlScopeExtensionSingleton.getInstance()
        .PARTICIPANT_DELEGATE[getCallId()]?.activeParticipantFlow

internal val CallControlScope.participants: MutableStateFlow<Set<Participant>>?
    @ExperimentalAppActions
    @RequiresApi(Build.VERSION_CODES.O)
    get() = CallControlScopeExtensionSingleton.getInstance()
        .PARTICIPANT_DELEGATE[getCallId()]?.participantsFlow

internal val CallControlScope.raisedHandParticipants: MutableStateFlow<Set<Participant>>?
    @ExperimentalAppActions
    @RequiresApi(Build.VERSION_CODES.O)
    get() = CallControlScopeExtensionSingleton.getInstance()
        .PARTICIPANT_DELEGATE[getCallId()]?.raisedHandParticipantsFlow