CallCompat.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.internal

import android.os.Build
import android.os.Bundle
import android.telecom.Call
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.core.telecom.CallsManager
import androidx.core.telecom.extensions.Capability
import androidx.core.telecom.extensions.CapabilityExchange
import androidx.core.telecom.extensions.ParticipantClientActions
import androidx.core.telecom.extensions.ParticipantClientActionsImpl
import androidx.core.telecom.internal.utils.CapabilityExchangeUtils
import androidx.core.telecom.util.ExperimentalAppActions
import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit
import kotlin.math.min
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.withTimeout

@ExperimentalAppActions
@RequiresApi(Build.VERSION_CODES.O)
internal class CallCompat(
    private val call: Call,
    val scope: CoroutineScope,
) {
    internal val icsCapabilities = mutableListOf<Capability>()

    @VisibleForTesting
    internal var capExchangeSetupComplete = false

    internal lateinit var onParticipantInitializationComplete: (ParticipantClientActions) -> Unit
    internal lateinit var participantStateListener: ParticipantClientActionsImpl

    companion object {
        /**
         * Current capability exchange version
         */
        internal const val CAPABILITY_EXCHANGE_VERSION = 1

        private val TAG = CallCompat::class.simpleName

        fun toCallCompat(call: Call, scope: CoroutineScope, init: CallCompat.() -> Unit):
            CallCompat {
            Log.i(TAG, "toCallCompat; call = $call")
            val callCompat = CallCompat(call, scope)
            callCompat.init()
            return callCompat
        }
    }

    fun toCall(): Call {
        return call
    }

    internal fun getParticipantClientActions(): Result<ParticipantClientActions> {
        Log.i(TAG, "getParticipantClientActions")
        return if (this::participantStateListener.isInitialized) {
            if (participantStateListener.mIsInitializationComplete) {
                Result.success(participantStateListener)
            } else {
                Result.failure(IllegalAccessException("ParticipantClientActions not setup yet."))
            }
        } else {
            Result.failure(IllegalAccessException("The participantStateListener field in " +
                "CallCompat was not initialized."))
        }
    }

    internal fun addCapability(capability: Capability) {
        Log.i(TAG, "addCapability capability = $capability")
        // This is called by extensions to include their capabilities to the call.
        icsCapabilities.add(capability)
    }

    internal fun addExtension(onInitializationComplete: (pca: ParticipantClientActions) -> Unit) {
        Log.i(TAG, "addExtension")
        onParticipantInitializationComplete = onInitializationComplete
    }

    /**
     * Initiate capability exchange negotiation between ICS and VOIP app. The acknowledgement begins
     * when the ICS sends a call event with [CallsManager.EVENT_JETPACK_CAPABILITY_EXCHANGE] to
     * notify the VOIP app to begin capability exchange negotiation. At that point, 3 stages of
     * acknowledgement are required between the two parties in order for negotiation to succeed.
     *
     * This entails the ICS side waiting for the VOIP app to communicate its supported capabilities,
     * the VOIP side waiting for the ICS side to communicate its supported capabilities, and the
     * VOIP side signaling the ICS side that feature setup (negotiation) is complete. If any one of
     * the aforementioned stages of ACK fails (i.e. timeout), the negotiation will fail.
     *
     * Note: Negotiation is only supported by InCallServices that support capability exchange
     * ([InCallServiceCompat.CAPABILITY_EXCHANGE]).
     *
     * @return the capability negotiation status.
     * between the ICS and VOIP app.
     */
    internal suspend fun startCapabilityExchange() {
        Log.i(TAG, "startCapabilityExchange: Starting capability negotiation with VOIP app...")

        // Initialize binder for facilitating IPC (capability exchange) between ICS and VOIP app
        // and notify VOIP app via a call event.
        val capExchange = CapabilityExchange()
        val extras = Bundle()
        extras.putBinder(CallsManager.EXTRA_CAPABILITY_EXCHANGE_BINDER, capExchange)
        extras.putInt(
            CallsManager.EXTRA_CAPABILITY_EXCHANGE_VERSION,
            CAPABILITY_EXCHANGE_VERSION
        )
        call.sendCallEvent(CallsManager.EVENT_JETPACK_CAPABILITY_EXCHANGE, extras)

        // Launch a new coroutine from the context of the current coroutine and wait for task to
        // complete.
        scope.async {
            beginCapabilityNegotiationAck(capExchange)
        }.await()
    }

    /**
     * Helper to start acknowledgement process for capability negotiation between the ICS and VOIP
     * app.
     */
    private suspend fun beginCapabilityNegotiationAck(capExchange: CapabilityExchange) {
        Log.i(TAG, "beginCapabilityNegotiationAck")

        try {
            withTimeout(CapabilityExchangeUtils.CAPABILITY_NEGOTIATION_COROUTINE_TIMEOUT) {
                // Wait for VOIP app to return its supported capabilities.
                if (capExchange.beginExchangeLatch.await(
                        CapabilityExchangeUtils.CAPABILITY_EXCHANGE_TIMEOUT,
                        TimeUnit.MILLISECONDS)) {
                    Log.i(TAG, "beginCapabilityNegotiationAck beginExchange returned from " +
                        "the VOIP side.")

                    setupSupportedCapabilities(capExchange)

                    Log.i(TAG, "beginCapabilityNegotiationAck: " +
                        "Completed capability exchange feature set up.")
                    capExchangeSetupComplete = true
                }
            }
        } catch (e: Exception) {
            when (e) {
                is CancellationException -> {
                    Log.i(
                        TAG, "beginCapabilityNegotiationAck: Capability negotiation job " +
                            "timed out in ICS side."
                    )
                    completeParticipantCapExchangeUnsupported()
                    // Todo: complete other extensions exceptionally
                }
                else -> {
                    // Handle the case where the VOIP app dies:
                    Log.i(
                        TAG, "beginCapabilityNegotiationAck: Remote party threw exception = $e"
                    )
                    completeParticipantCapExchangeUnsupported()
                    // Todo: complete other extensions exceptionally
                }
            }
        }
    }

    /***********************************************************************************************
     *                           Helpers
     *********************************************************************************************/

    internal fun setupSupportedCapabilities(capExchange: CapabilityExchange) {
        val voipCaps: Set<Capability> = capExchange.voipCapabilities.toSet()
        for (icsCap in icsCapabilities) {
            // Check if the VoIP app supports this capability:
            val voipCap: Capability? = voipCaps.find {
                it.featureId == icsCap.featureId
            }

            // If so, then initialize the listener and send the relevant callback:
            if (voipCap != null) {
                val negotiatedActions = icsCap.supportedActions
                    .intersect(voipCap.supportedActions.toSet())
                val minExtVersion = min(icsCap.featureVersion,
                    voipCap.featureVersion)

                when (icsCap.featureId) {
                    CallsManager.PARTICIPANT -> initializeParticipantListenerAndInformVoipApp(
                        negotiatedActions, minExtVersion, capExchange)
                    CallsManager.CALL_ICON -> initializeCallIconListenerAndInformVoipApp(
                        negotiatedActions, minExtVersion, capExchange)
                }
            } else {
                when (icsCap.featureId) {
                    CallsManager.PARTICIPANT -> completeParticipantCapExchangeUnsupported()
                    CallsManager.CALL_ICON -> completeCallIconCapExchangeUnsupported()
                }
            }
        }
    }

    private fun initializeParticipantListenerAndInformVoipApp(
        negotiatedParticipantActions: Set<Int>,
        minVersion: Int,
        capExchange: CapabilityExchange
    ) {
        participantStateListener = ParticipantClientActionsImpl(scope, negotiatedParticipantActions,
            onParticipantInitializationComplete)
        capExchange.capabilityExchangeListener.onCreateParticipantExtension(
            minVersion,
            negotiatedParticipantActions.toIntArray(),
            participantStateListener)
    }

    private fun initializeCallIconListenerAndInformVoipApp(
        negotiatedCallIconActions: Set<Int>,
        minVersion: Int,
        capExchange: CapabilityExchange
    ) {
        Log.i(TAG, "initializeCallIconListenerAndInformVoipApp: size of negotiatedActions" +
            " = ${negotiatedCallIconActions.size}, version = $minVersion, " +
            "capExchange = $capExchange")
        // Todo: initialize ICallDetailsListener and send onCreateCallDetailsExtension.
    }

    private fun completeParticipantCapExchangeUnsupported() {
        // complete the call cap exchange exceptionally and let Telecom take care of the cleanup:
        participantStateListener = ParticipantClientActionsImpl(scope, emptySet()) {}
        participantStateListener.mIsParticipantExtensionSupported = false
        onParticipantInitializationComplete(participantStateListener)
    }

    private fun completeCallIconCapExchangeUnsupported() {
        Log.i(TAG, "completeCallIconCapExchangeUnsupported")
        // Todo: inform the ICS app that voip doesn't support CallDetails extension
    }
}