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