1 /*
<lambda>null2  * Copyright 2025 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.core.telecom.extensions
18 
19 import android.net.Uri
20 import android.os.Build
21 import android.os.Bundle
22 import android.os.Parcelable
23 import android.telecom.Call
24 import android.util.Log
25 import androidx.annotation.RequiresApi
26 import androidx.core.telecom.extensions.Extensions.CALL_ICON
27 import androidx.core.telecom.extensions.Extensions.LOCAL_CALL_SILENCE
28 import androidx.core.telecom.extensions.Extensions.MEETING_SUMMARY
29 import androidx.core.telecom.internal.CapabilityExchangeListenerRemote
30 import androidx.core.telecom.internal.CapabilityExchangeRepository
31 import androidx.core.telecom.util.ExperimentalAppActions
32 import kotlinx.coroutines.CompletableDeferred
33 import kotlinx.coroutines.CoroutineScope
34 import kotlinx.coroutines.flow.Flow
35 import kotlinx.coroutines.flow.MutableStateFlow
36 import kotlinx.coroutines.flow.first
37 import kotlinx.coroutines.flow.launchIn
38 import kotlinx.coroutines.flow.onEach
39 import kotlinx.coroutines.launch
40 
41 /**
42  * Processes extras-based extensions for a Telecom [Call]. This class handles the extraction,
43  * processing, and propagation of call-related data (like speaker, participant count, call icon, and
44  * local silence state) received via `Bundle` extras. It interacts with the
45  * `CapabilityExchangeRepository` to set up extensions and manage communication with a remote
46  * endpoint. This class is designed to work within a `CoroutineScope` to handle asynchronous
47  * operations and flow updates.
48  *
49  * @param callScope The [CoroutineScope] in which extension-related operations are performed. This
50  *   scope is used for launching coroutines that observe and update state flows.
51  * @param call The [Call] instance for which extensions are being processed.
52  */
53 @OptIn(ExperimentalAppActions::class)
54 @RequiresApi(Build.VERSION_CODES.O)
55 internal class ExtrasCallExtensionProcessor(
56     private val callScope: CoroutineScope,
57     private val call: Call
58 ) {
59     companion object {
60         private const val TAG = "ECEP"
61         /** Set on Connections that are using ConnectionService+AUTO specific extension layer. */
62         internal const val EXTRA_VOIP_API_VERSION = "android.telecom.extra.VOIP_API_VERSION"
63 
64         /**
65          * String value (name of current speaker). Null will not be displayed, empty strings will be
66          * indicative of no current speaker but that the app still wishes to display speaker info.
67          */
68         internal const val EXTRA_CURRENT_SPEAKER = "android.telecom.extra.CURRENT_SPEAKER"
69 
70         /**
71          * Integer value. Null values will not be displayed, values >= 0 will be shown by supported
72          * UI’s.
73          */
74         internal const val EXTRA_PARTICIPANT_COUNT = "android.telecom.extra.PARTICIPANT_COUNT"
75 
76         /**
77          * URI value for an image to be displayed to represent the current call (overrides contact
78          * image in Auto). Supported URI types will be resource URI’s and content provider URI’s.
79          */
80         internal const val EXTRA_CALL_IMAGE_URI = "android.telecom.extra.CALL_IMAGE_URI"
81 
82         /**
83          * Extra to be included to indicate that the app uses local call microphone silence rather
84          * than the default global mute.
85          */
86         internal const val EXTRA_USE_LOCAL_CALL_SILENCE_CAPABILITY =
87             "android.telecom.extra.USE_LOCAL_CALL_SILENCE_CAPABILITY"
88 
89         /**
90          * Extra to be included to indicate that the call is currently able to have its call silence
91          * state modified.
92          */
93         internal const val EXTRA_CALL_SILENCE_AVAILABILITY =
94             "android.telecom.extra.CALL_SILENCE_AVAILABILITY"
95 
96         /** Extra associated with the {@code boolean} call microphone silence state extra. */
97         internal const val EXTRA_LOCAL_CALL_SILENCE_STATE =
98             "android.telecom.extra.LOCAL_CALL_SILENCE_STATE"
99 
100         /**
101          * Event received when an ICS is requesting a change to the call silence state. Will be
102          * packaged with the {@link #EXTRA_LOCAL_CALL_SILENCE_STATE} and a {@code boolean} value.
103          */
104         internal const val EVENT_LOCAL_CALL_SILENCE_STATE_CHANGED =
105             "android.telecom.event.LOCAL_CALL_SILENCE_STATE_CHANGED"
106     }
107 
108     private val mProcessedKeys = mutableSetOf<String>()
109     private val mSpeakerNameFlow = MutableStateFlow("")
110     private val mParticipantCountFlow = MutableStateFlow(0)
111     private val mUriFlow = MutableStateFlow<Uri>(Uri.EMPTY)
112 
113     private var mHasLocalCallSilenceCapability = true // one-time set
114     private var mCurrentlyUsingLocalCallSilence = true // dynamic
115     private val mLocalCallSilenceFlow = MutableStateFlow(false)
116 
117     /**
118      * Processes call extensions based on a [Flow] of [Call.Details]. This function sets up the
119      * necessary extensions using a [CapabilityExchangeRepository], collects updates from the
120      * provided [detailsFlow], and returns a [CapabilityExchangeResult]. It uses a
121      * [CompletableDeferred] to ensure all asynchronous operations related to extension setup are
122      * completed before returning.
123      *
124      * @param detailsFlow A [Flow] of [Call.Details], providing updates to the call's state and
125      *   extras.
126      * @return A [CapabilityExchangeResult] containing the negotiated capabilities, or `null` if no
127      *   relevant extras were found.
128      */
129     internal suspend fun handleExtrasExtensionsFromVoipApp(
130         detailsFlow: Flow<Call.Details>
131     ): CapabilityExchangeResult? {
132         Log.i(TAG, "handleExtrasExtensionsFromVoipApp: consuming extras")
133         val callbackRepository = CapabilityExchangeRepository(callScope)
134         initMeetingExtension(callbackRepository)
135         initCallIconExtension(callbackRepository)
136         initLocalSilenceExtension(callbackRepository)
137         callScope.launch { detailsFlow.collect { details -> processExtras(details.extras) } }
138         return getExtensionsUpdateFromNewExtras(detailsFlow.first(), callbackRepository)
139     }
140 
141     /**
142      * Extracts extension updates from [Call.Details] and returns a [CapabilityExchangeResult]. This
143      * method retrieves the extras from the provided [details], determines the VoIP API version, and
144      * constructs a set of supported capabilities. It then calls [processExtras] to handle the
145      * individual extra values.
146      *
147      * @param details The current [Call.Details] of the call.
148      * @param r The [CapabilityExchangeRepository] instance for capability exchange.
149      * @return A [CapabilityExchangeResult] representing the updated capabilities and remote
150      *   listener, or `null` if no relevant extras are found.
151      */
152     private suspend fun getExtensionsUpdateFromNewExtras(
153         details: Call.Details,
154         r: CapabilityExchangeRepository
155     ): CapabilityExchangeResult? {
156         val extras = details.extras?.takeIf { it.size() > 0 } ?: return null
157 
158         val apiVersion = extras.getInt(EXTRA_VOIP_API_VERSION, 0)
159         if (extras.containsKey(EXTRA_USE_LOCAL_CALL_SILENCE_CAPABILITY)) {
160             mHasLocalCallSilenceCapability =
161                 extras.getBoolean(EXTRA_USE_LOCAL_CALL_SILENCE_CAPABILITY)
162         }
163         val voipCapabilities =
164             setOf(
165                 getVoipMeetingSummaryCapability(apiVersion),
166                 getVoipIconCapability(apiVersion),
167                 getVoipLocalCallSilenceCapability(apiVersion)
168             )
169 
170         processExtras(extras)
171 
172         return CapabilityExchangeResult(
173             voipCapabilities,
174             CapabilityExchangeListenerRemote(r.listener)
175         )
176     }
177 
178     /**
179      * Processes the provided [extras] [Bundle], extracting values for known keys and emitting them
180      * to the corresponding internal state flows. This function handles the logic for dealing with
181      * extras that may be present in some updates but absent in others.
182      *
183      * @param extras The [Bundle] containing the call's extras, or `null` if no extras are present.
184      */
185     private suspend fun processExtras(extras: Bundle?) {
186         val currentKeys = extras?.keySet() ?: emptySet()
187 
188         processKey(
189             key = EXTRA_CALL_IMAGE_URI,
190             extras = extras,
191             currentKeys = currentKeys,
192             getValue = { b -> b.getParcelableCompat(EXTRA_CALL_IMAGE_URI, Uri::class.java) },
193             defaultValue = Uri.EMPTY,
194             flow = mUriFlow
195         )
196 
197         processKey(
198             key = EXTRA_PARTICIPANT_COUNT,
199             extras = extras,
200             currentKeys = currentKeys,
201             getValue = { b -> b.getInt(EXTRA_PARTICIPANT_COUNT) },
202             defaultValue = 0,
203             flow = mParticipantCountFlow
204         )
205         processKey(
206             key = EXTRA_CURRENT_SPEAKER,
207             extras = extras,
208             currentKeys = currentKeys,
209             getValue = { b -> b.getString(EXTRA_CURRENT_SPEAKER) },
210             defaultValue = "",
211             flow = mSpeakerNameFlow
212         )
213 
214         if (extras?.containsKey(EXTRA_CALL_SILENCE_AVAILABILITY) == true) {
215             mCurrentlyUsingLocalCallSilence = extras.getBoolean(EXTRA_CALL_SILENCE_AVAILABILITY)
216         }
217         if (mHasLocalCallSilenceCapability && mCurrentlyUsingLocalCallSilence) {
218             processKey(
219                 key = EXTRA_LOCAL_CALL_SILENCE_STATE,
220                 extras = extras,
221                 currentKeys = currentKeys,
222                 getValue = { b -> b.getBoolean(EXTRA_LOCAL_CALL_SILENCE_STATE) },
223                 defaultValue = false,
224                 flow = mLocalCallSilenceFlow
225             )
226         } else {
227             Log.w(TAG, "processExtras: attempted to toggle LCS but global mute is enabled")
228         }
229 
230         mProcessedKeys.addAll(currentKeys)
231     }
232 
233     /**
234      * Processes a single extra key. If the [key] is present in [currentKeys], the [getValue] lambda
235      * is used to extract the value from the extras [Bundle], and the value is emitted to the
236      * provided [flow]. If the [key] was previously processed but is now missing, the [defaultValue]
237      * is emitted to the [flow].
238      *
239      * @param key The string key of the extra to process.
240      * @param currentKeys A set of strings representing the keys currently present in the extras.
241      * @param getValue A lambda that takes a [Bundle] and returns the value associated with the
242      *   [key], or `null` if the value is not found or is of the wrong type.
243      * @param defaultValue The default value to emit if the key is not present or was previously
244      *   present but is now missing.
245      * @param flow The [MutableStateFlow] to which the extracted value (or default value) will be
246      *   emitted.
247      */
248     private suspend fun <T> processKey(
249         key: String,
250         extras: Bundle?,
251         currentKeys: Set<String>,
252         getValue: (Bundle) -> T?,
253         defaultValue: T,
254         flow: MutableStateFlow<T>
255     ) {
256         if (currentKeys.contains(key)) {
257             mProcessedKeys.add(key)
258             val value = extras?.let { getValue(it) } ?: defaultValue // Safely get, default if null
259             flow.emit(value)
260         } else if (mProcessedKeys.contains(key)) { // Key was present, now missing
261             flow.emit(defaultValue)
262         }
263     }
264 
265     /**
266      * Initializes the meeting summary extension by setting up listeners for speaker name and
267      * participant count updates. These updates are propagated to the remote endpoint via the
268      * provided [CapabilityExchangeRepository]. `finishSync()` is called on the binder to signal the
269      * completion of the initialization.
270      *
271      * @param r The [CapabilityExchangeRepository] instance used to register the extension and
272      *   communicate with the remote endpoint.
273      */
274     private fun initMeetingExtension(r: CapabilityExchangeRepository) {
275         r.onMeetingSummaryExtension = { coroutineScope, binder ->
276             mSpeakerNameFlow.onEach { binder.updateCurrentSpeaker(it) }.launchIn(coroutineScope)
277             mParticipantCountFlow
278                 .onEach { binder.updateParticipantCount(it) }
279                 .launchIn(coroutineScope)
280             binder.finishSync()
281         }
282     }
283 
284     /**
285      * Initializes the local call silence extension. This sets up a listener for changes to the
286      * local call silence state and provides an implementation of [ILocalSilenceActions] to handle
287      * remote requests to modify the silence state.
288      *
289      * @param r The [CapabilityExchangeRepository] used to register the extension.
290      */
291     private fun initLocalSilenceExtension(r: CapabilityExchangeRepository) {
292         r.onCreateLocalCallSilenceExtension = { coroutineScope, _, binder ->
293             mLocalCallSilenceFlow
294                 .onEach { binder.updateIsLocallySilenced(it) }
295                 .launchIn(coroutineScope)
296             val remoteExtensionBinder =
297                 object : ILocalSilenceActions.Stub() {
298                     override fun setIsLocallySilenced(
299                         isLocallySilenced: Boolean,
300                         cb: IActionsResultCallback?
301                     ) {
302                         call.sendCallEvent(
303                             EVENT_LOCAL_CALL_SILENCE_STATE_CHANGED,
304                             Bundle().apply {
305                                 putBoolean(EXTRA_LOCAL_CALL_SILENCE_STATE, isLocallySilenced)
306                             }
307                         )
308                         cb?.onSuccess()
309                     }
310                 }
311             binder.finishSync(remoteExtensionBinder)
312         }
313     }
314 
315     /**
316      * Initializes the call icon extension by setting up a listener for call icon URI updates. These
317      * updates are propagated to the remote endpoint via the provided
318      * [CapabilityExchangeRepository]. `finishSync()` is called on the binder to signal the
319      * completion of the initialization.
320      *
321      * @param r The [CapabilityExchangeRepository] instance used to register the extension and
322      *   communicate with the remote endpoint.
323      */
324     private fun initCallIconExtension(r: CapabilityExchangeRepository) {
325         r.onCreateCallIconExtension = { coroutineScope, _, _, binder ->
326             mUriFlow.onEach { binder.updateCallIconUri(it) }.launchIn(coroutineScope)
327             binder.finishSync()
328         }
329     }
330 
331     /**
332      * Creates a [Capability] object representing the meeting summary extension.
333      *
334      * @param version The VoIP API version.
335      * @return A [Capability] object for the meeting summary extension.
336      */
337     private fun getVoipMeetingSummaryCapability(version: Int): Capability {
338         return Capability().apply {
339             featureId = MEETING_SUMMARY
340             featureVersion = version
341             supportedActions = emptySet<Int>().toIntArray()
342         }
343     }
344 
345     /**
346      * Creates a [Capability] object representing the call icon extension.
347      *
348      * @param version The VoIP API version.
349      * @return A [Capability] object for the call icon extension.
350      */
351     private fun getVoipIconCapability(version: Int): Capability {
352         return Capability().apply {
353             featureId = CALL_ICON
354             featureVersion = version
355             supportedActions = emptySet<Int>().toIntArray()
356         }
357     }
358 
359     /**
360      * Creates a [Capability] object representing the local call silence extension.
361      *
362      * @param version The VoIP API version.
363      * @return A [Capability] object for the local call silence extension.
364      */
365     internal fun getVoipLocalCallSilenceCapability(version: Int): Capability {
366         return Capability().apply {
367             featureId = LOCAL_CALL_SILENCE
368             featureVersion = version
369             supportedActions = emptySet<Int>().toIntArray()
370         }
371     }
372 
373     private fun <T : Parcelable> Bundle?.getParcelableCompat(key: String, clazz: Class<T>): T? {
374         if (this == null) return null
375 
376         return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
377             getParcelable(key, clazz)
378         } else {
379             @Suppress("DEPRECATION")
380             getParcelable(key) as? T
381         }
382     }
383 }
384