1 /* <lambda>null2 * Copyright 2023 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.test 18 19 import android.annotation.SuppressLint 20 import android.app.Activity 21 import android.app.NotificationManager 22 import android.content.Context 23 import android.content.Intent 24 import android.os.Bundle 25 import android.telecom.DisconnectCause 26 import android.util.Log 27 import android.widget.Button 28 import android.widget.CheckBox 29 import androidx.annotation.RequiresApi 30 import androidx.core.telecom.CallAttributesCompat 31 import androidx.core.telecom.CallAttributesCompat.Companion.CALL_TYPE_VIDEO_CALL 32 import androidx.core.telecom.CallAttributesCompat.Companion.DIRECTION_INCOMING 33 import androidx.core.telecom.CallAttributesCompat.Companion.DIRECTION_OUTGOING 34 import androidx.core.telecom.CallEndpointCompat 35 import androidx.core.telecom.CallsManager 36 import androidx.core.telecom.extensions.RaiseHandState 37 import androidx.core.telecom.test.Constants.Companion.ALL_CALL_CAPABILITIES 38 import androidx.core.telecom.test.Constants.Companion.INCOMING_NAME 39 import androidx.core.telecom.test.Constants.Companion.INCOMING_URI 40 import androidx.core.telecom.test.Constants.Companion.OUTGOING_NAME 41 import androidx.core.telecom.test.Constants.Companion.OUTGOING_URI 42 import androidx.core.telecom.test.NotificationsUtilities.Companion.IS_ANSWER_ACTION 43 import androidx.core.telecom.test.NotificationsUtilities.Companion.NOTIFICATION_CHANNEL_ID 44 import androidx.core.telecom.util.ExperimentalAppActions 45 import androidx.core.view.WindowCompat 46 import androidx.recyclerview.widget.LinearLayoutManager 47 import androidx.recyclerview.widget.RecyclerView 48 import kotlinx.coroutines.CoroutineExceptionHandler 49 import kotlinx.coroutines.CoroutineScope 50 import kotlinx.coroutines.Dispatchers 51 import kotlinx.coroutines.cancel 52 import kotlinx.coroutines.delay 53 import kotlinx.coroutines.flow.MutableStateFlow 54 import kotlinx.coroutines.flow.launchIn 55 import kotlinx.coroutines.flow.onEach 56 import kotlinx.coroutines.isActive 57 import kotlinx.coroutines.launch 58 59 @ExperimentalAppActions 60 @RequiresApi(34) 61 class CallingMainActivity : Activity() { 62 // Activity 63 private val TAG = CallingMainActivity::class.simpleName 64 private val mScope = CoroutineScope(Dispatchers.Default) 65 private lateinit var mContext: Context 66 private var mCurrentCallCount: Int = 0 67 // Telecom 68 private lateinit var mCallsManager: CallsManager 69 // Ongoing Call List 70 private var mRecyclerView: RecyclerView? = null 71 private var mCallObjects: ArrayList<CallRow> = ArrayList() 72 private lateinit var mAdapter: CallListAdapter 73 // Pre-Call Endpoint List 74 private var mPreCallEndpointsRecyclerView: RecyclerView? = null 75 private var mCurrentPreCallEndpoints: ArrayList<CallEndpointCompat> = arrayListOf() 76 private lateinit var mPreCallEndpointAdapter: PreCallEndpointsAdapter 77 // Notification 78 private var mNextNotificationId: Int = 1 79 private lateinit var mNotificationManager: NotificationManager 80 private val mNotificationActionInfoFlow: MutableStateFlow<NotificationActionInfo> = 81 MutableStateFlow(NotificationActionInfo(-1, false)) 82 83 /** 84 * NotificationActionInfo couples information propagated from the Call-Style notification on 85 * which action button was clicked (e.g. answer the call or decline ) * 86 */ 87 data class NotificationActionInfo(val id: Int, val isAnswer: Boolean) 88 89 override fun onCreate(savedInstanceState: Bundle?) { 90 Log.i(TAG, "onCreate") 91 WindowCompat.setDecorFitsSystemWindows(window, false) 92 super.onCreate(savedInstanceState) 93 setContentView(R.layout.activity_main) 94 mContext = applicationContext 95 initNotifications(mContext) 96 mCallsManager = CallsManager(this) 97 98 val raiseHandCheckBox = findViewById<CheckBox>(R.id.RaiseHandCheckbox) 99 val kickParticipantCheckBox = findViewById<CheckBox>(R.id.KickPartCheckbox) 100 val participantCheckBox = findViewById<CheckBox>(R.id.ParticipantsCheckbox) 101 102 participantCheckBox.setOnCheckedChangeListener { _, isChecked -> 103 if (!isChecked) { 104 raiseHandCheckBox.isEnabled = false 105 raiseHandCheckBox.isChecked = false 106 kickParticipantCheckBox.isEnabled = false 107 kickParticipantCheckBox.isChecked = false 108 } else { 109 raiseHandCheckBox.isEnabled = true 110 kickParticipantCheckBox.isEnabled = true 111 } 112 } 113 114 val registerPhoneAccountButton = findViewById<Button>(R.id.registerButton) 115 registerPhoneAccountButton.setOnClickListener { mScope.launch { registerPhoneAccount() } } 116 117 val fetchPreCallEndpointsButton = findViewById<Button>(R.id.preCallAudioEndpointsButton) 118 fetchPreCallEndpointsButton.setOnClickListener { 119 mScope.launch { fetchPreCallEndpoints(findViewById(R.id.cancelFlowButton)) } 120 } 121 122 val addOutgoingCallButton = findViewById<Button>(R.id.addOutgoingCall) 123 addOutgoingCallButton.setOnClickListener { 124 addCallWithAttributes( 125 CallAttributesCompat( 126 OUTGOING_NAME, 127 OUTGOING_URI, 128 DIRECTION_OUTGOING, 129 CALL_TYPE_VIDEO_CALL, 130 ALL_CALL_CAPABILITIES, 131 mPreCallEndpointAdapter.mSelectedCallEndpoint 132 ), 133 participantCheckBox.isChecked, 134 raiseHandCheckBox.isChecked, 135 kickParticipantCheckBox.isChecked 136 ) 137 } 138 139 val addIncomingCallButton = findViewById<Button>(R.id.addIncomingCall) 140 addIncomingCallButton.setOnClickListener { 141 addCallWithAttributes( 142 CallAttributesCompat( 143 INCOMING_NAME, 144 INCOMING_URI, 145 DIRECTION_INCOMING, 146 CALL_TYPE_VIDEO_CALL, 147 ALL_CALL_CAPABILITIES, 148 mPreCallEndpointAdapter.mSelectedCallEndpoint 149 ), 150 participantCheckBox.isChecked, 151 raiseHandCheckBox.isChecked, 152 kickParticipantCheckBox.isChecked 153 ) 154 } 155 156 // setup the adapters which hold the endpoint and call rows 157 mAdapter = CallListAdapter(mCallObjects, null, applicationContext) 158 mPreCallEndpointAdapter = PreCallEndpointsAdapter(mCurrentPreCallEndpoints) 159 160 // set up the view holders 161 mRecyclerView = findViewById(R.id.callListRecyclerView) 162 mRecyclerView?.layoutManager = LinearLayoutManager(this) 163 mRecyclerView?.adapter = mAdapter 164 mPreCallEndpointsRecyclerView = findViewById(R.id.endpointsRecyclerView) 165 mPreCallEndpointsRecyclerView?.layoutManager = LinearLayoutManager(this) 166 mPreCallEndpointsRecyclerView?.adapter = mPreCallEndpointAdapter 167 } 168 169 override fun onNewIntent(intent: Intent?) { 170 super.onNewIntent(intent) 171 Log.i(TAG, "onNewIntent: intent=[$intent]") 172 maybeHandleNotificationAction(intent) 173 } 174 175 private fun maybeHandleNotificationAction(intent: Intent?) { 176 if (intent != null) { 177 val id = intent.getIntExtra(NotificationsUtilities.NOTIFICATION_ID, -1) 178 if (id != -1) { 179 val isAnswer = intent.getBooleanExtra(IS_ANSWER_ACTION, false) 180 Log.i(TAG, "handleNotification: id=$id, isAnswer=$isAnswer") 181 mNotificationActionInfoFlow.value = NotificationActionInfo(id, isAnswer) 182 } 183 } 184 } 185 186 override fun onDestroy() { 187 super.onDestroy() 188 for (call in mCallObjects) { 189 CoroutineScope(Dispatchers.IO).launch { 190 try { 191 call.callObject.mCallControl?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) 192 } catch (e: Exception) { 193 Log.i(TAG, "onDestroy: exception hit trying to destroy") 194 } 195 } 196 } 197 NotificationsUtilities.deleteNotificationChannel(mContext) 198 } 199 200 private fun initNotifications(c: Context) { 201 NotificationsUtilities.initNotificationChannel(c) 202 mNotificationManager = c.getSystemService(NOTIFICATION_SERVICE) as NotificationManager 203 } 204 205 @SuppressLint("WrongConstant") 206 private fun registerPhoneAccount() { 207 var capabilities: @CallsManager.Companion.Capability Int = CallsManager.CAPABILITY_BASELINE 208 209 val videoCallingCheckBox = findViewById<CheckBox>(R.id.VideoCallingCheckBox) 210 if (videoCallingCheckBox.isChecked) { 211 capabilities = capabilities or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING 212 } 213 val streamingCheckBox = findViewById<CheckBox>(R.id.streamingCheckBox) 214 if (streamingCheckBox.isChecked) { 215 capabilities = capabilities or CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING 216 } 217 mCallsManager.registerAppWithTelecom(capabilities) 218 } 219 220 private fun addCallWithAttributes( 221 attributes: CallAttributesCompat, 222 isParticipantsEnabled: Boolean, 223 isRaiseHandEnabled: Boolean, 224 isKickParticipantEnabled: Boolean 225 ) { 226 Log.i(TAG, "addCallWithAttributes: attributes=$attributes") 227 val callObject = VoipCall(this, attributes, mNextNotificationId++) 228 229 try { 230 val handler = CoroutineExceptionHandler { _, exception -> 231 Log.i(TAG, "CoroutineExceptionHandler: handling e=$exception") 232 NotificationsUtilities.clearNotification(mContext, callObject.notificationId) 233 } 234 val job = 235 mScope.launch(handler) { 236 try { 237 if (isParticipantsEnabled) { 238 addCallWithExtensions( 239 attributes, 240 callObject, 241 isRaiseHandEnabled, 242 isKickParticipantEnabled 243 ) 244 } else { 245 addCall(attributes, callObject) 246 } 247 } finally { 248 NotificationsUtilities.clearNotification( 249 mContext, 250 callObject.notificationId 251 ) 252 Log.i(TAG, "addCallWithAttributes: finally block") 253 } 254 } 255 callObject.setJob(job) 256 } catch (e: Exception) { 257 logException(e, "addCallWithAttributes: catch outer") 258 NotificationsUtilities.clearNotification(mContext, callObject.notificationId) 259 } 260 } 261 262 private suspend fun addCall(attributes: CallAttributesCompat, callObject: VoipCall) { 263 mCallsManager.addCall( 264 attributes, 265 callObject.mOnAnswerLambda, 266 callObject.mOnDisconnectLambda, 267 callObject.mOnSetActiveLambda, 268 callObject.mOnSetInActiveLambda, 269 ) { 270 postNotification(attributes, callObject) 271 mPreCallEndpointAdapter.mSelectedCallEndpoint = null 272 // inject client control interface into the VoIP call object 273 callObject.onCallStateChanged( 274 when (attributes.direction) { 275 DIRECTION_OUTGOING -> "Outgoing" 276 DIRECTION_INCOMING -> "Incoming" 277 else -> "?" 278 } 279 ) 280 callObject.setCallId(getCallId().toString()) 281 callObject.setCallControl(this) 282 283 launch { 284 mNotificationActionInfoFlow.collect { 285 if (it.id == callObject.notificationId) { 286 if (it.isAnswer) { 287 answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL) 288 } else { 289 disconnect(DisconnectCause(DisconnectCause.LOCAL)) 290 } 291 handleUpdateToNotification(it, attributes, callObject) 292 } 293 } 294 } 295 // Collect updates 296 launch { currentCallEndpoint.collect { callObject.onCallEndpointChanged(it) } } 297 298 launch { availableEndpoints.collect { callObject.onAvailableCallEndpointsChanged(it) } } 299 300 launch { isMuted.collect { callObject.onMuteStateChanged(it) } } 301 addCallRow(callObject) 302 } 303 } 304 305 private fun handleUpdateToNotification( 306 it: NotificationActionInfo, 307 attributes: CallAttributesCompat, 308 callObject: VoipCall 309 ) { 310 if (it.isAnswer) { 311 NotificationsUtilities.updateNotificationToOngoing( 312 mContext, 313 callObject.notificationId, 314 NOTIFICATION_CHANNEL_ID, 315 attributes.displayName.toString() 316 ) 317 } else { 318 NotificationsUtilities.clearNotification(mContext, callObject.notificationId) 319 } 320 } 321 322 @OptIn(ExperimentalAppActions::class) 323 private suspend fun addCallWithExtensions( 324 attributes: CallAttributesCompat, 325 callObject: VoipCall, 326 isRaiseHandEnabled: Boolean = false, 327 isKickParticipantEnabled: Boolean = false 328 ) { 329 mCallsManager.addCallWithExtensions( 330 attributes, 331 callObject.mOnAnswerLambda, 332 callObject.mOnDisconnectLambda, 333 callObject.mOnSetActiveLambda, 334 callObject.mOnSetInActiveLambda, 335 ) { 336 val initLocalCallSilenceValue = false 337 callObject.onLocalCallSilenceUpdate(initLocalCallSilenceValue) 338 val lcsE = 339 addLocalCallSilenceExtension(initLocalCallSilenceValue) { 340 callObject.onLocalCallSilenceUpdate(it) 341 } 342 callObject.mLocalCallSilenceExtension = lcsE 343 344 val iconExtension = addCallIconExtension(callObject.getIconUri()!!) 345 346 val participants = ParticipantsExtensionManager() 347 val participantExtension = 348 addParticipantExtension( 349 initialParticipants = participants.participants.value.map { it.toParticipant() } 350 ) 351 var raiseHandState: RaiseHandState? = null 352 if (isRaiseHandEnabled) { 353 raiseHandState = 354 participantExtension.addRaiseHandSupport { 355 participants.onRaisedHandStateChanged(it) 356 } 357 } 358 if (isKickParticipantEnabled) { 359 participantExtension.addKickParticipantSupport { 360 participants.onKickParticipant(it) 361 } 362 } 363 onCall { 364 postNotification(attributes, callObject) 365 mPreCallEndpointAdapter.mSelectedCallEndpoint = null 366 // inject client control interface into the VoIP call object 367 callObject.onCallStateChanged( 368 when (attributes.direction) { 369 DIRECTION_OUTGOING -> "Outgoing" 370 DIRECTION_INCOMING -> "Incoming" 371 else -> "?" 372 } 373 ) 374 callObject.setCallId(getCallId().toString()) 375 callObject.setCallControl(this) 376 callObject.setParticipantControl( 377 ParticipantControl( 378 onParticipantAdded = participants::addParticipant, 379 onParticipantRemoved = participants::removeParticipant 380 ) 381 ) 382 383 callObject.mIconExtensionControl = 384 VoipCall.IconControl(onUriChanged = iconExtension::updateCallIconUri) 385 386 addCallRow(callObject) 387 launch { 388 mNotificationActionInfoFlow.collect { 389 if (it.id == callObject.notificationId) { 390 if (it.isAnswer) { 391 answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL) 392 } else { 393 disconnect(DisconnectCause(DisconnectCause.LOCAL)) 394 } 395 handleUpdateToNotification(it, attributes, callObject) 396 } 397 } 398 } 399 // Collect updates 400 participants.participants 401 .onEach { 402 participantExtension.updateParticipants(it.map { p -> p.toParticipant() }) 403 participantExtension.updateActiveParticipant( 404 it.firstOrNull { p -> p.isActive }?.toParticipant() 405 ) 406 raiseHandState?.updateRaisedHands( 407 it.filter { p -> p.isHandRaised }.map { p -> p.toParticipant() } 408 ) 409 callObject.onParticipantsChanged(it) 410 } 411 .launchIn(this) 412 413 launch { 414 while (isActive) { 415 delay(1000) 416 participants.changeParticipantStates() 417 } 418 } 419 420 launch { currentCallEndpoint.collect { callObject.onCallEndpointChanged(it) } } 421 422 launch { 423 availableEndpoints.collect { callObject.onAvailableCallEndpointsChanged(it) } 424 } 425 426 launch { isMuted.collect { callObject.onMuteStateChanged(it) } } 427 } 428 } 429 } 430 431 private fun fetchPreCallEndpoints(cancelFlowButton: Button) { 432 val endpointsFlow = mCallsManager.getAvailableStartingCallEndpoints() 433 CoroutineScope(Dispatchers.Default).launch { 434 launch { 435 val endpointsCoroutineScope = this 436 Log.i(TAG, "fetchEndpoints: consuming endpoints") 437 endpointsFlow.collect { 438 for (endpoint in it) { 439 Log.i(TAG, "fetchEndpoints: endpoint=[$endpoint}") 440 } 441 cancelFlowButton.setOnClickListener { 442 mPreCallEndpointAdapter.mSelectedCallEndpoint = null 443 endpointsCoroutineScope.cancel() 444 updatePreCallEndpoints(null) 445 } 446 updatePreCallEndpoints(it) 447 } 448 // At this point, the endpointsCoroutineScope has been canceled 449 updatePreCallEndpoints(null) 450 } 451 } 452 } 453 454 private fun postNotification(attributes: CallAttributesCompat, voipCall: VoipCall) { 455 val notification = 456 NotificationsUtilities.createInitialCallStyleNotification( 457 mContext, 458 voipCall.notificationId, 459 NOTIFICATION_CHANNEL_ID, 460 attributes.displayName.toString(), 461 attributes.direction == DIRECTION_OUTGOING 462 ) 463 mNotificationManager.notify(voipCall.notificationId, notification) 464 } 465 466 private fun logException(e: Exception, prefix: String) { 467 Log.i(TAG, "$prefix: e=[$e], e.msg=[${e.message}], e.stack:${e.printStackTrace()}") 468 } 469 470 private fun addCallRow(callObject: VoipCall) { 471 mCallObjects.add(CallRow(++mCurrentCallCount, callObject)) 472 callObject.setCallAdapter(mAdapter) 473 updateCallList() 474 } 475 476 private fun updateCallList() { 477 runOnUiThread { mAdapter.notifyDataSetChanged() } 478 } 479 480 private fun updatePreCallEndpoints(newEndpoints: List<CallEndpointCompat>?) { 481 runOnUiThread { 482 mCurrentPreCallEndpoints.clear() 483 if (newEndpoints != null) { 484 mCurrentPreCallEndpoints.addAll(newEndpoints) 485 } 486 mPreCallEndpointAdapter.notifyDataSetChanged() 487 } 488 } 489 } 490