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