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