1 /*
<lambda>null2  * Copyright 2024 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.ui.calling
18 
19 import android.content.Context
20 import android.graphics.Bitmap
21 import android.net.Uri
22 import android.telecom.PhoneAccount
23 import android.telecom.PhoneAccountHandle
24 import android.telephony.PhoneNumberUtils
25 import android.telephony.TelephonyManager
26 import androidx.core.content.getSystemService
27 import androidx.core.telecom.CallControlResult
28 import androidx.core.telecom.CallException
29 import androidx.core.telecom.test.services.AudioRoute
30 import androidx.core.telecom.test.services.CallAudioEndpoint
31 import androidx.core.telecom.test.services.CallData
32 import androidx.core.telecom.test.services.CallIconData
33 import androidx.core.telecom.test.services.CallState
34 import androidx.core.telecom.test.services.Capability
35 import androidx.core.telecom.test.services.LocalCallSilenceData
36 import androidx.core.telecom.test.services.MeetingSummaryData
37 import androidx.core.telecom.test.services.ParticipantExtensionData
38 import androidx.core.telecom.test.services.RemoteCallProvider
39 import androidx.core.telecom.util.ExperimentalAppActions
40 import androidx.lifecycle.ViewModel
41 import java.util.Locale
42 import kotlinx.coroutines.flow.Flow
43 import kotlinx.coroutines.flow.map
44 
45 /**
46  * ViewModel responsible for maintaining the connection to the [RemoteCallProvider] as well as
47  * converting call/extension state to the associated UI specific state.
48  */
49 class OngoingCallsViewModel(private val callProvider: RemoteCallProvider = RemoteCallProvider()) :
50     ViewModel() {
51     companion object {
52         val UnknownAudioEndpoint =
53             CallAudioEndpoint(id = "UNKNOWN", audioRoute = AudioRoute.UNKNOWN)
54         val UnknownAudioUiState =
55             AudioEndpointUiState(id = "UNKNOWN", name = "UNKNOWN", audioRoute = AudioRoute.UNKNOWN)
56     }
57 
58     /** connect to the remote call provider with the given context */
59     fun connectService(context: Context) {
60         callProvider.connectService(context)
61     }
62 
63     /** disconnect from the remote call provider */
64     fun disconnectService() {
65         callProvider.disconnectService()
66     }
67 
68     /**
69      * stream the [CallData] from the [RemoteCallProvider] when the service is connected and map
70      * that data to associated [CallUiState].
71      */
72     fun streamCallData(context: Context): Flow<List<CallUiState>> {
73         return callProvider.streamCallData().map { dataState ->
74             dataState.map { mapToUiState(context, it) }
75         }
76     }
77 
78     /** Stream the global mute state of the device as long as the service is connected. */
79     fun streamMuteData(): Flow<Boolean> {
80         return callProvider.streamMuteData()
81     }
82 
83     /**
84      * Stream the current audio endpoint of the device as long as the service is connected and we
85      * are in call.
86      */
87     fun streamCurrentEndpointAudioData(): Flow<AudioEndpointUiState> {
88         return callProvider
89             .streamCurrentEndpointData()
90             .map { it ?: UnknownAudioEndpoint }
91             .map(::mapToUiAudioState)
92     }
93 
94     /**
95      * Stream the available endpoints of the device as long as the service is connected and we are
96      * in call.
97      */
98     fun streamAvailableEndpointAudioData(): Flow<List<AudioEndpointUiState>> {
99         return callProvider
100             .streamAvailableEndpointData()
101             .map { it.map(::mapToUiAudioState) }
102             .map { endpoints -> endpoints.sortedWith(compareBy({ it.audioRoute }, { it.name })) }
103     }
104 
105     /**
106      * Change the global mute state of the device
107      *
108      * @param isMuted true if the device should be muted, false otherwise
109      */
110     fun onChangeMuteState(isMuted: Boolean) {
111         callProvider.onChangeMuteState(isMuted)
112     }
113 
114     /**
115      * Change the audio route of the active call
116      *
117      * @param id The ID of the endpoint from [AudioEndpointUiState.id]
118      */
119     suspend fun onChangeAudioRoute(id: String) {
120         callProvider.onChangeAudioRoute(id)
121     }
122 
123     /** Perform a map operation from [CallData] to [CallUiState] */
124     private fun mapToUiState(context: Context, fullCallData: CallData): CallUiState {
125         return CallUiState(
126             id = fullCallData.callData.id,
127             name = fullCallData.callData.contactName ?: fullCallData.callData.name,
128             photo = fullCallData.callData.contactUri,
129             number =
130                 formatPhoneNumber(
131                     context,
132                     fullCallData.callData.phoneAccountHandle,
133                     fullCallData.callData.number
134                 ),
135             state = fullCallData.callData.state,
136             validTransition =
137                 getValidTransition(fullCallData.callData.state, fullCallData.callData.capabilities),
138             direction = fullCallData.callData.direction,
139             callType = fullCallData.callData.callType,
140             onStateChanged = { fullCallData.callData.onStateChanged(it) },
141             meetingSummaryUiState = mapToUiMeetingSummaryExtension(fullCallData.meetingSummaryData),
142             participantUiState = mapToUiParticipantExtension(fullCallData.participantExtensionData),
143             localCallSilenceUiState = mapToUiLocalSilenceExtension(fullCallData.localSilenceData),
144             callIconUiState = mapToUiCallIconExtension(fullCallData.callIconData)
145         )
146     }
147 
148     /** map [CallIconData] to [MeetingSummaryUiState] */
149     @OptIn(ExperimentalAppActions::class)
150     private fun mapToUiMeetingSummaryExtension(
151         meetingSummaryData: MeetingSummaryData?
152     ): MeetingSummaryUiState {
153         return if (meetingSummaryData == null) {
154             MeetingSummaryUiState("", 0)
155         } else {
156             MeetingSummaryUiState(
157                 meetingSummaryData.activeSpeaker,
158                 meetingSummaryData.participantCount
159             )
160         }
161     }
162 
163     /** map [CallIconData] to [CallIconExtensionUiState] */
164     @OptIn(ExperimentalAppActions::class)
165     private fun mapToUiCallIconExtension(
166         callIconExtensionData: CallIconData?
167     ): CallIconExtensionUiState {
168         return if (callIconExtensionData == null) {
169             CallIconExtensionUiState(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888))
170         } else {
171             CallIconExtensionUiState(callIconExtensionData.callIconUri)
172         }
173     }
174 
175     @OptIn(ExperimentalAppActions::class)
176     private fun mapToUiLocalSilenceExtension(
177         localCallSilenceExtensionData: LocalCallSilenceData?
178     ): LocalCallSilenceExtensionUiState {
179         if (localCallSilenceExtensionData == null) {
180             return LocalCallSilenceExtensionUiState(false, {}, null)
181         }
182         return LocalCallSilenceExtensionUiState(
183             localCallSilenceExtensionData.isLocallySilenced,
184             localCallSilenceExtensionData.onInCallServiceUiUpdate,
185             localCallSilenceExtensionData.extension
186         )
187     }
188 
189     /** Perform a map ooperation from [ParticipantExtensionData] to [ParticipantExtensionUiState] */
190     @OptIn(ExperimentalAppActions::class)
191     private fun mapToUiParticipantExtension(
192         participantExtensionData: ParticipantExtensionData?
193     ): ParticipantExtensionUiState? {
194         if (participantExtensionData == null || !participantExtensionData.isSupported) return null
195         return ParticipantExtensionUiState(
196             isRaiseHandSupported =
197                 participantExtensionData.raiseHandData?.raiseHandAction?.isSupported ?: false,
198             isKickParticipantSupported =
199                 participantExtensionData.kickParticipantData?.kickParticipantAction?.isSupported
200                     ?: false,
201             onRaiseHandStateChanged = {
202                 participantExtensionData.raiseHandData
203                     ?.raiseHandAction
204                     ?.requestRaisedHandStateChange(it)
205             },
206             participants = mapUiParticipants(participantExtensionData)
207         )
208     }
209 
210     /** map [ParticipantExtensionData] to [ParticipantExtensionUiState] */
211     @OptIn(ExperimentalAppActions::class)
212     private fun mapUiParticipants(
213         participantExtensionData: ParticipantExtensionData
214     ): List<ParticipantUiState> {
215         return participantExtensionData.participants.map { p ->
216             ParticipantUiState(
217                 name = p.name.toString(),
218                 isActive = participantExtensionData.activeParticipant == p,
219                 isSelf = participantExtensionData.selfParticipant?.id == p.id,
220                 isHandRaised =
221                     participantExtensionData.raiseHandData?.raisedHands?.contains(p) ?: false,
222                 onKickParticipant = {
223                     participantExtensionData.kickParticipantData
224                         ?.kickParticipantAction
225                         ?.requestKickParticipant(p)
226                         ?: CallControlResult.Error(CallException.ERROR_CALL_IS_NOT_BEING_TRACKED)
227                 }
228             )
229         }
230     }
231 
232     /** format the phone number to a user friendly form */
233     private fun formatPhoneNumber(
234         context: Context,
235         phoneAccountHandle: PhoneAccountHandle,
236         number: Uri
237     ): String {
238         val isTel = PhoneAccount.SCHEME_TEL == number.scheme
239         if (!isTel) return number.schemeSpecificPart
240         val tm: TelephonyManager? =
241             context
242                 .getSystemService<TelephonyManager>()
243                 ?.createForPhoneAccountHandle(phoneAccountHandle)
244         val iso = tm?.networkCountryIso ?: Locale.getDefault().country
245         return PhoneNumberUtils.formatNumber(number.schemeSpecificPart, iso)
246     }
247 
248     /** Determine the valid [CallStateTransition] based on [CallState] and call [Capability] */
249     private fun getValidTransition(
250         state: CallState,
251         capabilities: List<Capability>
252     ): CallStateTransition {
253         return when (state) {
254             CallState.INCOMING -> CallStateTransition.ANSWER
255             CallState.DIALING -> CallStateTransition.NONE
256             CallState.ACTIVE -> {
257                 if (capabilities.contains(Capability.SUPPORTS_HOLD)) {
258                     CallStateTransition.HOLD
259                 } else {
260                     CallStateTransition.NONE
261                 }
262             }
263             CallState.HELD -> CallStateTransition.UNHOLD
264             CallState.DISCONNECTING -> CallStateTransition.NONE
265             CallState.DISCONNECTED -> CallStateTransition.NONE
266             CallState.UNKNOWN -> CallStateTransition.NONE
267         }
268     }
269 
270     /** Map from [CallAudioEndpoint] to [AudioEndpointUiState] */
271     private fun mapToUiAudioState(endpoint: CallAudioEndpoint): AudioEndpointUiState {
272         return AudioEndpointUiState(
273             id = endpoint.id,
274             name = endpoint.frameworkName ?: getAudioEndpointRouteName(endpoint.audioRoute),
275             audioRoute = endpoint.audioRoute
276         )
277     }
278 
279     /** Get the user friendly endpoint route name */
280     private fun getAudioEndpointRouteName(audioState: AudioRoute): String {
281         return when (audioState) {
282             AudioRoute.EARPIECE -> "Earpiece"
283             AudioRoute.SPEAKER -> "Speaker"
284             AudioRoute.HEADSET -> "Headset"
285             AudioRoute.BLUETOOTH -> "Bluetooth"
286             AudioRoute.STREAMING -> "Streaming"
287             AudioRoute.UNKNOWN -> "Unknown"
288         }
289     }
290 }
291