From 1c4b9e5f89983abc971fe67b401a93f77b6bc5f7 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Fri, 26 May 2023 01:38:15 +0000 Subject: [PATCH 01/41] Telecom Inital Project --- app/build.gradle.kts | 2 +- samples/README.md | 2 + samples/connectivity/telecom/README.md | 4 + samples/connectivity/telecom/build.gradle.kts | 30 ++ .../telecom/src/main/AndroidManifest.xml | 34 ++ .../connectivity/telecom/TelecomManager.kt | 327 ++++++++++++++++++ .../connectivity/telecom/TelecomSample.kt | 190 ++++++++++ .../connectivity/telecom/VoipViewModel.kt | 30 ++ .../telecom/screen/CallStatusUI.kt | 115 ++++++ .../telecom/screen/DialerScreen.kt | 63 ++++ .../telecom/screen/IncallScreen.kt | 162 +++++++++ 11 files changed, 958 insertions(+), 1 deletion(-) create mode 100644 samples/connectivity/telecom/README.md create mode 100644 samples/connectivity/telecom/build.gradle.kts create mode 100644 samples/connectivity/telecom/src/main/AndroidManifest.xml create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8658b264..e9efc9c3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,7 +30,7 @@ java { android { namespace = "com.example.platform.app" - compileSdk = 33 + compileSdkPreview = "UpsideDownCake" defaultConfig { applicationId = "com.example.platform.app" diff --git a/samples/README.md b/samples/README.md index a4a0738d..e550508f 100644 --- a/samples/README.md +++ b/samples/README.md @@ -58,5 +58,7 @@ Basic usage of Picture-in-Picture mode showcasing a video activity PiP and an ac Shows the recommended flow to request single runtime permissions - [Speakable Text](accessibility/src/main/java/com/example/platform/accessibility/SpeakableText.kt): The sample demonstrates the importance of having proper labels for +- [TelecomSample](connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt): +TODO: Add description - [TextSpan](user-interface/text/src/main/java/com/example/platform/ui/text/TextSpan.kt): buildSpannedString is useful for quickly building a rich text. diff --git a/samples/connectivity/telecom/README.md b/samples/connectivity/telecom/README.md new file mode 100644 index 00000000..b84119a4 --- /dev/null +++ b/samples/connectivity/telecom/README.md @@ -0,0 +1,4 @@ +# TelecomSample samples + +// TODO: provide minimal instructions +``` \ No newline at end of file diff --git a/samples/connectivity/telecom/build.gradle.kts b/samples/connectivity/telecom/build.gradle.kts new file mode 100644 index 00000000..96d6134a --- /dev/null +++ b/samples/connectivity/telecom/build.gradle.kts @@ -0,0 +1,30 @@ + +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +plugins { + id("com.example.platform.sample") +} + +android { + namespace = "com.example.platform.connectivity.telecom" +} + +dependencies { + implementation("androidx.core:core-telecom:1.0.0-alpha01") + implementation(project(mapOf("path" to ":samples:connectivity:audio"))) +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/AndroidManifest.xml b/samples/connectivity/telecom/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1cce70a3 --- /dev/null +++ b/samples/connectivity/telecom/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt new file mode 100644 index 00000000..71040a92 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt @@ -0,0 +1,327 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.os.Build +import android.telecom.DisconnectCause +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallControlCallback +import androidx.core.telecom.CallControlScope +import androidx.core.telecom.CallEndpointCompat +import androidx.core.telecom.CallsManager +import com.example.platform.connectivity.audio.datasource.AudioLoopSource +import com.example.platform.connectivity.telecom.screen.CallStatusUI +import com.example.platform.connectivity.telecom.screen.EndPointUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class TelecomManager(private val context: Context, val viewModel: VoipViewModel) { + + companion object { + const val APP_SCHEME = "MyCustomScheme" + const val ALL_CALL_CAPABILITIES = (CallAttributesCompat.SUPPORTS_SET_INACTIVE + or CallAttributesCompat.SUPPORTS_STREAM or CallAttributesCompat.SUPPORTS_TRANSFER) + + // outgoing attributes constants + const val OUTGOING_NAME = "Darth Maul" + val OUTGOING_URI: Uri = Uri.fromParts(APP_SCHEME, "", "") + + // Define the minimal set of properties to start an outgoing call + var OUTGOING_CALL_ATTRIBUTES = CallAttributesCompat( + OUTGOING_NAME, + OUTGOING_URI, + CallAttributesCompat.DIRECTION_OUTGOING, + ALL_CALL_CAPABILITIES, + ) + + // incoming attributes constants + const val INCOMING_NAME = "Sundar Pichai" + val INCOMING_URI: Uri = Uri.fromParts(APP_SCHEME, "", "") + + // Define all possible properties for CallAttributes + val INCOMING_CALL_ATTRIBUTES = + CallAttributesCompat( + INCOMING_NAME, + INCOMING_URI, + CallAttributesCompat.DIRECTION_INCOMING, + CallAttributesCompat.CALL_TYPE_VIDEO_CALL, + ALL_CALL_CAPABILITIES, + ) + } + + var callControlScope: CallControlScope? = null + var fakeCallSession = AudioLoopSource() + + private val callNotificationSource = CallNotificationSource(context) + private val coroutineScope = CoroutineScope(Dispatchers.IO) + var callsManager = CallsManager(context) + val callState = MutableStateFlow(CallState.NOCALL) + + + @SuppressLint("NewApi") + var availableEndpoint: Flow> = emptyFlow() + + var callStatus: Flow = + callState.map { callState -> + + getCallStatus(emptyList(), null, null, callState) + } + + + private fun getCallStatus( + audioDevices: List, + activeDevice: CallEndpointCompat?, + isMuted: Boolean?, + callState: CallState, + ): CallStatusUI { + var callStatusUI = CallStatusUI() + callStatusUI.caller = "John Doe" + // callStatusUI.isActive = isActive + + callStatusUI.currentAudioDevice = activeDevice?.name.toString() + + callStatusUI.isMuted = isMuted + + callStatusUI.audioDevices = audioDevices + + when (callState) { + CallState.INCOMING -> callStatusUI.callState = "Incoming Call" + CallState.OUTGOING -> callStatusUI.callState = "Dialing Out Call" + CallState.INCALL -> callStatusUI.callState = "In Call" + else -> { + callStatusUI.callState = "No Call" + } + } + + return callStatusUI + } + + enum class CallState { + NOCALL, + INCOMING, + OUTGOING, + INCALL + } + + init { + var capabilities: @CallsManager.Companion.Capability Int = + CallsManager.CAPABILITY_BASELINE or CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING + callsManager.registerAppWithTelecom(capabilities) + } + + fun makeOutGoingCall() { + callState.value = CallState.OUTGOING + makeCall(OUTGOING_CALL_ATTRIBUTES) + } + + fun fakeIncomingCall() { + makeCall(INCOMING_CALL_ATTRIBUTES) + } + + private fun makeCall(callAttributes: CallAttributesCompat) { + + CoroutineScope(Dispatchers.Unconfined).launch { + val coroutineScopes = this + callsManager.addCall(callAttributes) { + callControlScope = this + + setCallback(callControlCallback) + + availableEndpoints + .onEach { viewModel.availableAudioRoutes.value = it } + .launchIn(coroutineScopes) + + currentCallEndpoint + .onEach { viewModel.activeAudioRoute.value = it } + .launchIn(coroutineScope) + + isMuted + .onEach { viewModel.isMuted.value = it } + .launchIn(coroutineScope) + + /*availableEndpoint = + combine( + availableEndpoints, + currentCallEndpoint, + ) { availableDevices: List, activeDevice: CallEndpointCompat -> + availableDevices.map { + EndPointUI(isActive = activeDevice.name == it.name, it) + } + }*/ + + /* callStatus = combine( + availableEndpoints, + currentCallEndpoint, + callState + ) { availableDevices: List, activeDevice: CallEndpointCompat, callState: CallState -> + + //getCallStatus(availableDevices, activeDevice, false, callState) + }*/ + + + } + + if (callAttributes.direction == CallAttributesCompat.DIRECTION_INCOMING) { + onAnswerCall() + } else { + onCallActive() + } + + //this will start foreground service + //callNotificationSource.postOnGoingCall() + } + } + + fun onAnswerCall() { + coroutineScope.launch { + callControlScope?.let { callControlScope -> + if (callControlScope.answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL)) { + startCall() + } else { + //todo update error state + callState.value = CallState.NOCALL + } + } + } + } + + @RequiresApi(Build.VERSION_CODES.M) + fun onRejectCall() { + coroutineScope.launch { + callControlScope?.let { + it.disconnect(DisconnectCause(DisconnectCause.REJECTED)) + callState.value = CallState.NOCALL + } + } + } + + suspend fun onCallActive() { + callControlScope?.let { + if (it.setActive()) { + startCall() + } + } + } + + fun onToggleCallHold() { + coroutineScope.launch { + callControlScope?.let { + if ( viewModel.isActive.value) { + if (it.setInactive()) { + holdCall() + } + } else { + if (it.setActive()) { + startCall() + } + } + } + } + } + + @SuppressLint("NewApi") + fun OnHangUp() { + coroutineScope.launch { + callControlScope?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) + endCall() + } + } + + fun postIncomingcallNotification() { + //callNotificationSource.postIncomingCall() + } + + private fun startCall() { + + fakeCallSession.startAudioLoop() + viewModel.isActive.update { true } + viewModel.currentCallState.update { CallState.INCALL } + } + + private fun endCall() { + fakeCallSession.stopAudioLoop() + //callNotificationSource.onCancelNotification() + viewModel.isActive.update { false } + callStatus = + callState.map { callState -> + getCallStatus(emptyList(), null, null, callState) + } + viewModel.currentCallState.update { CallState.NOCALL } + viewModel.activeAudioRoute.update { null } + viewModel.availableAudioRoutes.update { emptyList() } + } + + private fun holdCall() { + fakeCallSession.stopAudioLoop() + viewModel.isActive.update { false } + viewModel.currentCallState.update { CallState.INCALL } + } + + fun setEndpoint(callEndpoint: CallEndpointCompat) { + coroutineScope.launch { + callControlScope?.requestEndpointChange(callEndpoint) + } + } + + private fun hasError(message: String) { + + } + + fun toggleMute(b: Boolean) { + + } + + fun toggleCallHold(b: Boolean) { + + } + + private val callControlCallback = object : CallControlCallback { + override suspend fun onSetActive(): Boolean { + startCall() + return true + } + + override suspend fun onSetInactive(): Boolean { + holdCall() + return true + } + + override suspend fun onAnswer(callType: Int): Boolean { + startCall() + return true + } + + override suspend fun onDisconnect(disconnectCause: DisconnectCause): Boolean { + endCall() + return true + } + } +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt new file mode 100644 index 00000000..a4b35267 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt @@ -0,0 +1,190 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.example.platform.connectivity.telecom + +import android.Manifest +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.example.platform.connectivity.telecom.screen.CallStatusUI +import com.example.platform.connectivity.telecom.screen.CallStatusWidget +import com.example.platform.connectivity.telecom.screen.DialerScreen +import com.example.platform.connectivity.telecom.screen.IncallScreen +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.rememberPermissionState +import com.google.android.catalog.framework.annotations.Sample + +@OptIn(ExperimentalPermissionsApi::class) +@Sample( + name = "TelecomSample", + description = "TODO: Add description", +) +class TelecomSample: ComponentActivity() { + + companion object { + lateinit var callViewModel: TelecomManager + } + + + @OptIn(ExperimentalPermissionsApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + callViewModel = TelecomManager(this, VoipViewModel()) + + setContent { + MaterialTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + // We should be using make_own_call permissions but this requires + // implementation of the telecom API to work correctly. + // Please see telecom example for full implementation + val multiplePermissionsState = + rememberMultiplePermissionsState( + listOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.MANAGE_OWN_CALLS, + ), + ) + + if (multiplePermissionsState.allPermissionsGranted) { + EntryPoint() + } else { + PermissionWidget(multiplePermissionsState) + } + } + } + } + } +} + +@Preview +@Composable +fun EntryPoint() { + Box(modifier = Modifier.fillMaxSize()) { + CallingStatus(TelecomSample.Companion.callViewModel) + Box(modifier = Modifier.align(Alignment.BottomCenter)) { + CallingBottomBar(TelecomSample.Companion.callViewModel) + } + } +} + +@Composable +fun CallingStatus(callViewModel: TelecomManager){ + + CallStatusWidget(callViewModel.viewModel) +} + +@Composable +fun CallingBottomBar(callViewModel: TelecomManager){ + + val callScreenState by callViewModel.viewModel.currentCallState.collectAsState() + + when(callScreenState){ + TelecomManager.CallState.INCALL -> { IncallScreen(callViewModel) } + TelecomManager.CallState.INCOMING -> { IncallScreen(callViewModel) } + TelecomManager.CallState.OUTGOING -> { OutgoingCall() } + else -> { DialerScreen(callViewModel) } + } +} + +@Composable +fun OutgoingCall(){ + Text( + text = "Dialing out...", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun PermissionWidget(permissionsState: MultiplePermissionsState) { + var showRationale by remember(permissionsState) { + mutableStateOf(false) + } + + if (showRationale) { + AlertDialog( + onDismissRequest = { showRationale = false }, + title = { + Text(text = "") + }, + text = { + Text(text = "") + }, + confirmButton = { + TextButton( + onClick = { + permissionsState.launchMultiplePermissionRequest() + }, + ) { + Text("Continue") + } + }, + dismissButton = { + TextButton( + onClick = { + showRationale = false + }, + ) { + Text("Dismiss") + } + }, + ) + } + + Button( + onClick = { + if (permissionsState.shouldShowRationale) { + showRationale = true + } else { + permissionsState.launchMultiplePermissionRequest() + } + }, + ) { + Text(text = "Grant Permission") + } +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt new file mode 100644 index 00000000..abc558f9 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import androidx.core.telecom.CallEndpointCompat +import kotlinx.coroutines.flow.MutableStateFlow + +class VoipViewModel { + val isMuted = MutableStateFlow(false) + val isActive = MutableStateFlow(false) + val activeAudioRoute : MutableStateFlow = MutableStateFlow(null) + val availableAudioRoutes : MutableStateFlow> = MutableStateFlow(emptyList()) + val currentCallState = MutableStateFlow(TelecomManager.CallState.NOCALL) + + var CallerName = "Jane Doe" +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt new file mode 100644 index 00000000..29c63ad2 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.telecom.CallEndpointCompat +import com.example.platform.connectivity.telecom.TelecomManager +import com.example.platform.connectivity.telecom.VoipViewModel + +class CallStatusUI { + var caller: String? = null + var isActive: Boolean = false + var currentAudioDevice: String? = null + var isMuted: Boolean? = false + var callState: String? = null + var audioDevices : List = emptyList() +} + +class EndPointUI(var isActive: Boolean, var callEndpoint: CallEndpointCompat) + +@Preview +@Composable +fun CallStatusWidget() { + val tempCaller = CallStatusUI() + tempCaller.caller = "Luke Hopkins" + tempCaller.isActive = false + tempCaller.currentAudioDevice = "Speaker" + tempCaller.isMuted = false + tempCaller.callState = "Not in call" + + // CallStatusWidget(tempCaller) +} + +@Composable +fun CallStatusWidget(callViewModel: VoipViewModel) { + + val callState by callViewModel.currentCallState.collectAsState() + val callStatus = when(callState) + { + TelecomManager.CallState.INCALL -> "In Call" + TelecomManager.CallState.INCOMING -> "Incoming Call" + TelecomManager.CallState.OUTGOING -> "Outgoing Call" + else -> "No Call" + } + + val activeDeviceName by callViewModel.activeAudioRoute.collectAsState() + val isMuted by callViewModel.isMuted.collectAsState() + val isActive by callViewModel.isActive.collectAsState() + val activeDevices by callViewModel.availableAudioRoutes.collectAsState() + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) { + Column(Modifier.padding(30.dp)) { + Text( + text = String.format("Call Status: %s", callStatus), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = String.format("Audio Device: %s", activeDeviceName?.name), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = String.format("Mute State: %b", isMuted), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = String.format("Active State: %b", isActive), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = String.format("Caller: %s", callViewModel.CallerName), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + + activeDevices.forEach{ + Text( + text = String.format("Caller: %s", it.name), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + } + } + } +} + diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt new file mode 100644 index 00000000..5891d5ad --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.screen + +import android.R +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import com.example.platform.connectivity.telecom.TelecomManager + +@Composable +fun DialerScreen(callViewModel: TelecomManager) { + + DialerBottomBar({callViewModel.makeOutGoingCall() }, { callViewModel.fakeIncomingCall()}) +} + +@Composable +fun DialerBottomBar( + onOutgoingCall: () -> Unit, + onIncomingCall: () -> Unit, +) { + NavigationBar { + NavigationBarItem( + icon = { + Icon( + painter = painterResource(id = R.drawable.sym_call_outgoing), + contentDescription = "Outgoing Call" + ) + }, + label = { Text("Outgoing Call") }, + selected = false, + onClick = onOutgoingCall + ) + NavigationBarItem( + icon = { + Icon( + painter = painterResource(id = R.drawable.sym_call_incoming), + contentDescription = "Incoming Call" + ) + }, + label = { Text("Incoming Call") }, + selected = false, + onClick = onIncomingCall + ) + } +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt new file mode 100644 index 00000000..a5d72d42 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.screen + +import android.R +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledIconToggleButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.core.telecom.CallEndpointCompat +import com.example.platform.connectivity.telecom.TelecomManager + +@Composable +fun IncallScreen(callViewModel: TelecomManager) { + val callEndPoints by callViewModel.viewModel.availableAudioRoutes.collectAsState() + val muteState by callViewModel.viewModel.isMuted.collectAsState() + + IncallBottomBar( + callEndPoints, + muteState, + callViewModel::toggleMute, + callViewModel::toggleCallHold, + { callViewModel.OnHangUp() }, + callViewModel::setEndpoint + ) +} + +@Composable +fun IncallBottomBar( + endPoints: List, + muteState: Boolean, + onMuteChanged: (Boolean) -> Unit, + onHoldCall: (Boolean) -> Unit, + onHangUp: () -> Unit, + onAudioDeviceSelected: (CallEndpointCompat) -> Unit, +) { + + var audioDeviceWidgetState by remember { mutableStateOf(false) } + + BottomAppBar( + actions = { + ToggleButton( + R.drawable.arrow_down_float, + R.drawable.arrow_down_float, + muteState, + onMuteChanged, + ) + Box { + IconButton(onClick = { audioDeviceWidgetState = !audioDeviceWidgetState }) { + Icon( + painter = painterResource(id = R.drawable.arrow_down_float), + contentDescription = "Localized description", + ) + } + DropdownMenu( + expanded = audioDeviceWidgetState, + onDismissRequest = { audioDeviceWidgetState = false }, + ) { + LazyColumn { + items(endPoints) { item: CallEndpointCompat -> + CallEndPointItem( + endPointUI = item, + onDeviceSelected = onAudioDeviceSelected, + ) + } + } + } + } + ToggleButton( + R.drawable.arrow_down_float, + R.drawable.arrow_down_float, + muteState, + onHoldCall, + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onHangUp, + containerColor = Color.Red, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + ) { + Icon( + painter = painterResource(id = R.drawable.arrow_down_float), + "Localized description", + ) + } + }, + ) +} + + +@Composable +fun ToggleButton( + positiveResID: Int, + negativeResID: Int, + toggleState: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + FilledIconToggleButton(checked = toggleState, onCheckedChange = onCheckedChange) { + if (toggleState) { + Icon(painter = painterResource(id = positiveResID), "Switch On") + } else { + Icon(painter = painterResource(id = negativeResID), "Switch Off") + } + } +} + +/** + * Displays the audio device with Icon and Text + */ +@SuppressLint("NewApi") +@Composable +private fun CallEndPointItem( + endPointUI: CallEndpointCompat, + onDeviceSelected: (CallEndpointCompat) -> Unit, +) { + DropdownMenuItem( + text = { Text(endPointUI.name.toString()) }, + onClick = { onDeviceSelected(endPointUI) }, + leadingIcon = { + Icon( + Icons.Outlined.Email, + contentDescription = null, + ) + }, + trailingIcon = { Text("F11", textAlign = TextAlign.Center) }, + ) +} \ No newline at end of file From 6e719beb9836c1c836cc8af6f20b21c7c99b86ee Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Fri, 26 May 2023 03:57:53 +0000 Subject: [PATCH 02/41] Audio Devices UI User can not select from different audio devices --- .../connectivity/telecom/TelecomManager.kt | 98 +++---------------- .../telecom/screen/IncallScreen.kt | 24 ++--- 2 files changed, 24 insertions(+), 98 deletions(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt index 71040a92..b414663a 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt @@ -21,7 +21,6 @@ import android.content.Context import android.net.Uri import android.os.Build import android.telecom.DisconnectCause -import android.util.Log import androidx.annotation.RequiresApi import androidx.core.telecom.CallAttributesCompat import androidx.core.telecom.CallControlCallback @@ -29,16 +28,9 @@ import androidx.core.telecom.CallControlScope import androidx.core.telecom.CallEndpointCompat import androidx.core.telecom.CallsManager import com.example.platform.connectivity.audio.datasource.AudioLoopSource -import com.example.platform.connectivity.telecom.screen.CallStatusUI -import com.example.platform.connectivity.telecom.screen.EndPointUI import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -80,49 +72,9 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) var callControlScope: CallControlScope? = null var fakeCallSession = AudioLoopSource() - private val callNotificationSource = CallNotificationSource(context) + // private val callNotificationSource = CallNotificationSource(context) private val coroutineScope = CoroutineScope(Dispatchers.IO) var callsManager = CallsManager(context) - val callState = MutableStateFlow(CallState.NOCALL) - - - @SuppressLint("NewApi") - var availableEndpoint: Flow> = emptyFlow() - - var callStatus: Flow = - callState.map { callState -> - - getCallStatus(emptyList(), null, null, callState) - } - - - private fun getCallStatus( - audioDevices: List, - activeDevice: CallEndpointCompat?, - isMuted: Boolean?, - callState: CallState, - ): CallStatusUI { - var callStatusUI = CallStatusUI() - callStatusUI.caller = "John Doe" - // callStatusUI.isActive = isActive - - callStatusUI.currentAudioDevice = activeDevice?.name.toString() - - callStatusUI.isMuted = isMuted - - callStatusUI.audioDevices = audioDevices - - when (callState) { - CallState.INCOMING -> callStatusUI.callState = "Incoming Call" - CallState.OUTGOING -> callStatusUI.callState = "Dialing Out Call" - CallState.INCALL -> callStatusUI.callState = "In Call" - else -> { - callStatusUI.callState = "No Call" - } - } - - return callStatusUI - } enum class CallState { NOCALL, @@ -138,7 +90,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } fun makeOutGoingCall() { - callState.value = CallState.OUTGOING + viewModel.currentCallState.update { CallState.OUTGOING } makeCall(OUTGOING_CALL_ATTRIBUTES) } @@ -155,7 +107,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) setCallback(callControlCallback) - availableEndpoints + availableEndpoints .onEach { viewModel.availableAudioRoutes.value = it } .launchIn(coroutineScopes) @@ -166,27 +118,6 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) isMuted .onEach { viewModel.isMuted.value = it } .launchIn(coroutineScope) - - /*availableEndpoint = - combine( - availableEndpoints, - currentCallEndpoint, - ) { availableDevices: List, activeDevice: CallEndpointCompat -> - availableDevices.map { - EndPointUI(isActive = activeDevice.name == it.name, it) - } - }*/ - - /* callStatus = combine( - availableEndpoints, - currentCallEndpoint, - callState - ) { availableDevices: List, activeDevice: CallEndpointCompat, callState: CallState -> - - //getCallStatus(availableDevices, activeDevice, false, callState) - }*/ - - } if (callAttributes.direction == CallAttributesCompat.DIRECTION_INCOMING) { @@ -207,7 +138,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) startCall() } else { //todo update error state - callState.value = CallState.NOCALL + endCall() } } } @@ -218,7 +149,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) coroutineScope.launch { callControlScope?.let { it.disconnect(DisconnectCause(DisconnectCause.REJECTED)) - callState.value = CallState.NOCALL + endCall() } } } @@ -231,14 +162,14 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } } - fun onToggleCallHold() { + fun toggleCallHold(b: Boolean) { coroutineScope.launch { callControlScope?.let { - if ( viewModel.isActive.value) { + if (!b) { if (it.setInactive()) { holdCall() - } - } else { + } + } else { if (it.setActive()) { startCall() } @@ -260,7 +191,6 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } private fun startCall() { - fakeCallSession.startAudioLoop() viewModel.isActive.update { true } viewModel.currentCallState.update { CallState.INCALL } @@ -270,10 +200,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) fakeCallSession.stopAudioLoop() //callNotificationSource.onCancelNotification() viewModel.isActive.update { false } - callStatus = - callState.map { callState -> - getCallStatus(emptyList(), null, null, callState) - } + viewModel.currentCallState.update { CallState.NOCALL } viewModel.activeAudioRoute.update { null } viewModel.availableAudioRoutes.update { emptyList() } @@ -296,12 +223,9 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } fun toggleMute(b: Boolean) { - + viewModel.isMuted.update { !viewModel.isMuted.value } } - fun toggleCallHold(b: Boolean) { - - } private val callControlCallback = object : CallControlCallback { override suspend fun onSetActive(): Boolean { diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt index a5d72d42..00dba659 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Phone import androidx.compose.material3.BottomAppBar import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -48,6 +49,7 @@ import com.example.platform.connectivity.telecom.TelecomManager fun IncallScreen(callViewModel: TelecomManager) { val callEndPoints by callViewModel.viewModel.availableAudioRoutes.collectAsState() val muteState by callViewModel.viewModel.isMuted.collectAsState() + val activeState by callViewModel.viewModel.isActive.collectAsState() IncallBottomBar( callEndPoints, @@ -55,6 +57,7 @@ fun IncallScreen(callViewModel: TelecomManager) { callViewModel::toggleMute, callViewModel::toggleCallHold, { callViewModel.OnHangUp() }, + activeState, callViewModel::setEndpoint ) } @@ -66,6 +69,7 @@ fun IncallBottomBar( onMuteChanged: (Boolean) -> Unit, onHoldCall: (Boolean) -> Unit, onHangUp: () -> Unit, + activeState: Boolean, onAudioDeviceSelected: (CallEndpointCompat) -> Unit, ) { @@ -90,20 +94,18 @@ fun IncallBottomBar( expanded = audioDeviceWidgetState, onDismissRequest = { audioDeviceWidgetState = false }, ) { - LazyColumn { - items(endPoints) { item: CallEndpointCompat -> - CallEndPointItem( - endPointUI = item, - onDeviceSelected = onAudioDeviceSelected, - ) - } + endPoints.forEach{ + CallEndPointItem( + endPointUI = it, + onDeviceSelected = onAudioDeviceSelected, + ) } } } ToggleButton( R.drawable.arrow_down_float, R.drawable.arrow_down_float, - muteState, + activeState, onHoldCall, ) }, @@ -152,11 +154,11 @@ private fun CallEndPointItem( text = { Text(endPointUI.name.toString()) }, onClick = { onDeviceSelected(endPointUI) }, leadingIcon = { + Icon( - Icons.Outlined.Email, + Icons.Outlined.Phone, contentDescription = null, ) - }, - trailingIcon = { Text("F11", textAlign = TextAlign.Center) }, + } ) } \ No newline at end of file From 4b7fab3cb7eb98358a4b3070cdde6ffe5e2b0f1d Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Fri, 26 May 2023 19:41:08 +0000 Subject: [PATCH 03/41] Outgoing Call Complete User flow complete for incoming call --- .../connectivity/telecom/TelecomManager.kt | 65 ++++++-------- .../connectivity/telecom/TelecomSample.kt | 20 ++--- .../connectivity/telecom/VoipViewModel.kt | 87 ++++++++++++++++++- .../telecom/screen/DialerScreen.kt | 19 ++-- .../telecom/screen/IncallScreen.kt | 31 +++---- .../src/main/res/drawable/call_end.xml | 9 ++ .../telecom/src/main/res/drawable/mic_off.xml | 9 ++ .../telecom/src/main/res/drawable/mic_on.xml | 9 ++ .../telecom/src/main/res/drawable/speaker.xml | 4 + 9 files changed, 180 insertions(+), 73 deletions(-) create mode 100644 samples/connectivity/telecom/src/main/res/drawable/call_end.xml create mode 100644 samples/connectivity/telecom/src/main/res/drawable/mic_off.xml create mode 100644 samples/connectivity/telecom/src/main/res/drawable/mic_on.xml create mode 100644 samples/connectivity/telecom/src/main/res/drawable/speaker.xml diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt index b414663a..67527777 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt @@ -72,8 +72,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) var callControlScope: CallControlScope? = null var fakeCallSession = AudioLoopSource() - // private val callNotificationSource = CallNotificationSource(context) - private val coroutineScope = CoroutineScope(Dispatchers.IO) + private val coroutineScope = CoroutineScope(Dispatchers.Unconfined) var callsManager = CallsManager(context) enum class CallState { @@ -90,18 +89,16 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } fun makeOutGoingCall() { - viewModel.currentCallState.update { CallState.OUTGOING } makeCall(OUTGOING_CALL_ATTRIBUTES) } - fun fakeIncomingCall() { + fun makeIncomingCall() { makeCall(INCOMING_CALL_ATTRIBUTES) } private fun makeCall(callAttributes: CallAttributesCompat) { - CoroutineScope(Dispatchers.Unconfined).launch { - val coroutineScopes = this + coroutineScope.launch { callsManager.addCall(callAttributes) { callControlScope = this @@ -109,7 +106,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) availableEndpoints .onEach { viewModel.availableAudioRoutes.value = it } - .launchIn(coroutineScopes) + .launchIn(coroutineScope) currentCallEndpoint .onEach { viewModel.activeAudioRoute.value = it } @@ -118,19 +115,26 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) isMuted .onEach { viewModel.isMuted.value = it } .launchIn(coroutineScope) - } - if (callAttributes.direction == CallAttributesCompat.DIRECTION_INCOMING) { - onAnswerCall() - } else { - onCallActive() + onCallReady(callAttributes.direction) } + //this will start foreground service //callNotificationSource.postOnGoingCall() } } + private fun onCallReady(callDirection: Int) { + coroutineScope.launch { + if (callDirection == CallAttributesCompat.DIRECTION_INCOMING) { + viewModel.onCallStateChanged(CallState.INCOMING) + } else { + viewModel.onCallStateChanged(CallState.OUTGOING) + } + } + } + fun onAnswerCall() { coroutineScope.launch { callControlScope?.let { callControlScope -> @@ -154,32 +158,27 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } } - suspend fun onCallActive() { + suspend fun setCallActive() : Boolean { callControlScope?.let { if (it.setActive()) { - startCall() + startCall() } } + return false } - fun toggleCallHold(b: Boolean) { - coroutineScope.launch { - callControlScope?.let { - if (!b) { - if (it.setInactive()) { - holdCall() - } - } else { - if (it.setActive()) { - startCall() - } - } + suspend fun setCallInActive() : Boolean { + callControlScope?.let { + if (it.setInactive()) { + holdCall() + return true } } + return false } @SuppressLint("NewApi") - fun OnHangUp() { + fun hangUp() { coroutineScope.launch { callControlScope?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) endCall() @@ -193,17 +192,13 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) private fun startCall() { fakeCallSession.startAudioLoop() viewModel.isActive.update { true } - viewModel.currentCallState.update { CallState.INCALL } + viewModel.onCallStateChanged(CallState.INCALL) } private fun endCall() { fakeCallSession.stopAudioLoop() - //callNotificationSource.onCancelNotification() viewModel.isActive.update { false } - - viewModel.currentCallState.update { CallState.NOCALL } - viewModel.activeAudioRoute.update { null } - viewModel.availableAudioRoutes.update { emptyList() } + viewModel.onCallStateChanged(CallState.NOCALL) } private fun holdCall() { @@ -218,10 +213,6 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } } - private fun hasError(message: String) { - - } - fun toggleMute(b: Boolean) { viewModel.isMuted.update { !viewModel.isMuted.value } } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt index a4b35267..c57251d4 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt @@ -61,7 +61,7 @@ import com.google.android.catalog.framework.annotations.Sample class TelecomSample: ComponentActivity() { companion object { - lateinit var callViewModel: TelecomManager + lateinit var callViewModel: VoipViewModel } @@ -69,7 +69,7 @@ class TelecomSample: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - callViewModel = TelecomManager(this, VoipViewModel()) + callViewModel = VoipViewModel(this) setContent { MaterialTheme { @@ -90,7 +90,7 @@ class TelecomSample: ComponentActivity() { ) if (multiplePermissionsState.allPermissionsGranted) { - EntryPoint() + EntryPoint(callViewModel) } else { PermissionWidget(multiplePermissionsState) } @@ -102,25 +102,25 @@ class TelecomSample: ComponentActivity() { @Preview @Composable -fun EntryPoint() { +fun EntryPoint(callViewModel: VoipViewModel) { Box(modifier = Modifier.fillMaxSize()) { - CallingStatus(TelecomSample.Companion.callViewModel) + CallingStatus(callViewModel) Box(modifier = Modifier.align(Alignment.BottomCenter)) { - CallingBottomBar(TelecomSample.Companion.callViewModel) + CallingBottomBar(callViewModel) } } } @Composable -fun CallingStatus(callViewModel: TelecomManager){ +fun CallingStatus(callViewModel: VoipViewModel){ - CallStatusWidget(callViewModel.viewModel) + CallStatusWidget(callViewModel) } @Composable -fun CallingBottomBar(callViewModel: TelecomManager){ +fun CallingBottomBar(callViewModel: VoipViewModel){ - val callScreenState by callViewModel.viewModel.currentCallState.collectAsState() + val callScreenState by callViewModel.currentCallState.collectAsState() when(callScreenState){ TelecomManager.CallState.INCALL -> { IncallScreen(callViewModel) } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt index abc558f9..51d6a755 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt @@ -16,10 +16,19 @@ package com.example.platform.connectivity.telecom +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import androidx.core.telecom.CallAttributesCompat import androidx.core.telecom.CallEndpointCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch -class VoipViewModel { +class VoipViewModel(context: Context): ViewModel(){ val isMuted = MutableStateFlow(false) val isActive = MutableStateFlow(false) val activeAudioRoute : MutableStateFlow = MutableStateFlow(null) @@ -27,4 +36,80 @@ class VoipViewModel { val currentCallState = MutableStateFlow(TelecomManager.CallState.NOCALL) var CallerName = "Jane Doe" + + private val telecomManager = TelecomManager(context, this) + + fun onMakeCall(callDirection: Int){ + when(callDirection){ + CallAttributesCompat.DIRECTION_INCOMING -> telecomManager.makeIncomingCall() + else -> telecomManager.makeOutGoingCall() + } + } + + fun toggleHoldCall(toggle: Boolean){ + viewModelScope.launch { + val hasError = when(toggle) { + + true -> telecomManager.setCallActive() + + false -> telecomManager.setCallInActive() + } + + } + } + + fun toggleMute(toggle: Boolean){ + telecomManager.toggleMute(toggle) + } + + fun answerCall(){ + telecomManager.onAnswerCall() + } + + + fun rejectCall(){ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + telecomManager.onRejectCall() + } + } + + fun disconnectCall(){ + telecomManager.hangUp() + } + + fun setEndpoint(callEndpointCompat: CallEndpointCompat) { + telecomManager.setEndpoint(callEndpointCompat) + } + fun onCallStateChanged(callState : TelecomManager.CallState){ + currentCallState.update { callState } + + when(callState){ + TelecomManager.CallState.OUTGOING -> {fakeDialingCall()} + TelecomManager.CallState.INCOMING -> {} + TelecomManager.CallState.INCALL -> {} + else -> { onDialerScreen() } + } + } + + /** + * Fake a dialing out Call + * Waits for 5 Seconds before setting the call to active + */ + private fun fakeDialingCall(){ + viewModelScope.launch { + delay(5000) + telecomManager.setCallActive() + } + } + + private fun onDialerScreen() { + isActive.update { false } + currentCallState.update { TelecomManager.CallState.NOCALL } + activeAudioRoute.update { null } + availableAudioRoutes.update { emptyList() } + } + + fun onErrorMessage(){ + + } } \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt index 5891d5ad..e975d309 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt @@ -23,12 +23,15 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.painterResource -import com.example.platform.connectivity.telecom.TelecomManager +import androidx.core.telecom.CallAttributesCompat +import com.example.platform.connectivity.telecom.VoipViewModel @Composable -fun DialerScreen(callViewModel: TelecomManager) { - - DialerBottomBar({callViewModel.makeOutGoingCall() }, { callViewModel.fakeIncomingCall()}) +fun DialerScreen(callViewModel: VoipViewModel) { + DialerBottomBar( + { callViewModel.onMakeCall(CallAttributesCompat.DIRECTION_OUTGOING) }, + { callViewModel.onMakeCall(CallAttributesCompat.DIRECTION_INCOMING) }, + ) } @Composable @@ -41,23 +44,23 @@ fun DialerBottomBar( icon = { Icon( painter = painterResource(id = R.drawable.sym_call_outgoing), - contentDescription = "Outgoing Call" + contentDescription = "Outgoing Call", ) }, label = { Text("Outgoing Call") }, selected = false, - onClick = onOutgoingCall + onClick = onOutgoingCall, ) NavigationBarItem( icon = { Icon( painter = painterResource(id = R.drawable.sym_call_incoming), - contentDescription = "Incoming Call" + contentDescription = "Incoming Call", ) }, label = { Text("Incoming Call") }, selected = false, - onClick = onIncomingCall + onClick = onIncomingCall, ) } } \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt index 00dba659..8a6a1776 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt @@ -19,10 +19,7 @@ package com.example.platform.connectivity.telecom.screen import android.R import android.annotation.SuppressLint import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.Phone import androidx.compose.material3.BottomAppBar import androidx.compose.material3.DropdownMenu @@ -41,22 +38,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign import androidx.core.telecom.CallEndpointCompat -import com.example.platform.connectivity.telecom.TelecomManager +import com.example.platform.connectivity.telecom.R.drawable +import com.example.platform.connectivity.telecom.VoipViewModel @Composable -fun IncallScreen(callViewModel: TelecomManager) { - val callEndPoints by callViewModel.viewModel.availableAudioRoutes.collectAsState() - val muteState by callViewModel.viewModel.isMuted.collectAsState() - val activeState by callViewModel.viewModel.isActive.collectAsState() +fun IncallScreen(callViewModel: VoipViewModel) { + val callEndPoints by callViewModel.availableAudioRoutes.collectAsState() + val muteState by callViewModel.isMuted.collectAsState() + val activeState by callViewModel.isActive.collectAsState() IncallBottomBar( callEndPoints, muteState, callViewModel::toggleMute, - callViewModel::toggleCallHold, - { callViewModel.OnHangUp() }, + callViewModel::toggleHoldCall, + { callViewModel.disconnectCall()}, activeState, callViewModel::setEndpoint ) @@ -78,15 +75,15 @@ fun IncallBottomBar( BottomAppBar( actions = { ToggleButton( - R.drawable.arrow_down_float, - R.drawable.arrow_down_float, + drawable.mic_off, + drawable.mic_on, muteState, onMuteChanged, ) Box { IconButton(onClick = { audioDeviceWidgetState = !audioDeviceWidgetState }) { Icon( - painter = painterResource(id = R.drawable.arrow_down_float), + painter = painterResource(id = drawable.speaker), contentDescription = "Localized description", ) } @@ -103,8 +100,8 @@ fun IncallBottomBar( } } ToggleButton( - R.drawable.arrow_down_float, - R.drawable.arrow_down_float, + R.drawable.ic_menu_call, + R.drawable.stat_sys_phone_call_on_hold, activeState, onHoldCall, ) @@ -116,7 +113,7 @@ fun IncallBottomBar( elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), ) { Icon( - painter = painterResource(id = R.drawable.arrow_down_float), + painter = painterResource(id = drawable.call_end), "Localized description", ) } diff --git a/samples/connectivity/telecom/src/main/res/drawable/call_end.xml b/samples/connectivity/telecom/src/main/res/drawable/call_end.xml new file mode 100644 index 00000000..a3d3bc0f --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/call_end.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/connectivity/telecom/src/main/res/drawable/mic_off.xml b/samples/connectivity/telecom/src/main/res/drawable/mic_off.xml new file mode 100644 index 00000000..31632df3 --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/mic_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/connectivity/telecom/src/main/res/drawable/mic_on.xml b/samples/connectivity/telecom/src/main/res/drawable/mic_on.xml new file mode 100644 index 00000000..1328b565 --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/mic_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/connectivity/telecom/src/main/res/drawable/speaker.xml b/samples/connectivity/telecom/src/main/res/drawable/speaker.xml new file mode 100644 index 00000000..6112ada9 --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/speaker.xml @@ -0,0 +1,4 @@ + + + From 51cefc864ee2acfc5d5e542225fa2413dbf78dd1 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Fri, 26 May 2023 01:38:15 +0000 Subject: [PATCH 04/41] Telecom Inital Project --- app/build.gradle.kts | 2 +- samples/README.md | 2 + samples/connectivity/telecom/README.md | 4 + samples/connectivity/telecom/build.gradle.kts | 30 ++ .../telecom/src/main/AndroidManifest.xml | 34 ++ .../connectivity/telecom/TelecomManager.kt | 327 ++++++++++++++++++ .../connectivity/telecom/TelecomSample.kt | 190 ++++++++++ .../connectivity/telecom/VoipViewModel.kt | 30 ++ .../telecom/screen/CallStatusUI.kt | 115 ++++++ .../telecom/screen/DialerScreen.kt | 63 ++++ .../telecom/screen/IncallScreen.kt | 162 +++++++++ 11 files changed, 958 insertions(+), 1 deletion(-) create mode 100644 samples/connectivity/telecom/README.md create mode 100644 samples/connectivity/telecom/build.gradle.kts create mode 100644 samples/connectivity/telecom/src/main/AndroidManifest.xml create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8658b264..e9efc9c3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,7 +30,7 @@ java { android { namespace = "com.example.platform.app" - compileSdk = 33 + compileSdkPreview = "UpsideDownCake" defaultConfig { applicationId = "com.example.platform.app" diff --git a/samples/README.md b/samples/README.md index fb8fc724..fb1600ba 100644 --- a/samples/README.md +++ b/samples/README.md @@ -64,5 +64,7 @@ Basic usage of Picture-in-Picture mode showcasing video playback Shows the recommended flow to request single runtime permissions - [Speakable Text](accessibility/src/main/java/com/example/platform/accessibility/SpeakableText.kt): The sample demonstrates the importance of having proper labels for +- [TelecomSample](connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt): +TODO: Add description - [TextSpan](user-interface/text/src/main/java/com/example/platform/ui/text/TextSpan.kt): buildSpannedString is useful for quickly building a rich text. diff --git a/samples/connectivity/telecom/README.md b/samples/connectivity/telecom/README.md new file mode 100644 index 00000000..b84119a4 --- /dev/null +++ b/samples/connectivity/telecom/README.md @@ -0,0 +1,4 @@ +# TelecomSample samples + +// TODO: provide minimal instructions +``` \ No newline at end of file diff --git a/samples/connectivity/telecom/build.gradle.kts b/samples/connectivity/telecom/build.gradle.kts new file mode 100644 index 00000000..96d6134a --- /dev/null +++ b/samples/connectivity/telecom/build.gradle.kts @@ -0,0 +1,30 @@ + +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +plugins { + id("com.example.platform.sample") +} + +android { + namespace = "com.example.platform.connectivity.telecom" +} + +dependencies { + implementation("androidx.core:core-telecom:1.0.0-alpha01") + implementation(project(mapOf("path" to ":samples:connectivity:audio"))) +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/AndroidManifest.xml b/samples/connectivity/telecom/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1cce70a3 --- /dev/null +++ b/samples/connectivity/telecom/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt new file mode 100644 index 00000000..71040a92 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt @@ -0,0 +1,327 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.os.Build +import android.telecom.DisconnectCause +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallControlCallback +import androidx.core.telecom.CallControlScope +import androidx.core.telecom.CallEndpointCompat +import androidx.core.telecom.CallsManager +import com.example.platform.connectivity.audio.datasource.AudioLoopSource +import com.example.platform.connectivity.telecom.screen.CallStatusUI +import com.example.platform.connectivity.telecom.screen.EndPointUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class TelecomManager(private val context: Context, val viewModel: VoipViewModel) { + + companion object { + const val APP_SCHEME = "MyCustomScheme" + const val ALL_CALL_CAPABILITIES = (CallAttributesCompat.SUPPORTS_SET_INACTIVE + or CallAttributesCompat.SUPPORTS_STREAM or CallAttributesCompat.SUPPORTS_TRANSFER) + + // outgoing attributes constants + const val OUTGOING_NAME = "Darth Maul" + val OUTGOING_URI: Uri = Uri.fromParts(APP_SCHEME, "", "") + + // Define the minimal set of properties to start an outgoing call + var OUTGOING_CALL_ATTRIBUTES = CallAttributesCompat( + OUTGOING_NAME, + OUTGOING_URI, + CallAttributesCompat.DIRECTION_OUTGOING, + ALL_CALL_CAPABILITIES, + ) + + // incoming attributes constants + const val INCOMING_NAME = "Sundar Pichai" + val INCOMING_URI: Uri = Uri.fromParts(APP_SCHEME, "", "") + + // Define all possible properties for CallAttributes + val INCOMING_CALL_ATTRIBUTES = + CallAttributesCompat( + INCOMING_NAME, + INCOMING_URI, + CallAttributesCompat.DIRECTION_INCOMING, + CallAttributesCompat.CALL_TYPE_VIDEO_CALL, + ALL_CALL_CAPABILITIES, + ) + } + + var callControlScope: CallControlScope? = null + var fakeCallSession = AudioLoopSource() + + private val callNotificationSource = CallNotificationSource(context) + private val coroutineScope = CoroutineScope(Dispatchers.IO) + var callsManager = CallsManager(context) + val callState = MutableStateFlow(CallState.NOCALL) + + + @SuppressLint("NewApi") + var availableEndpoint: Flow> = emptyFlow() + + var callStatus: Flow = + callState.map { callState -> + + getCallStatus(emptyList(), null, null, callState) + } + + + private fun getCallStatus( + audioDevices: List, + activeDevice: CallEndpointCompat?, + isMuted: Boolean?, + callState: CallState, + ): CallStatusUI { + var callStatusUI = CallStatusUI() + callStatusUI.caller = "John Doe" + // callStatusUI.isActive = isActive + + callStatusUI.currentAudioDevice = activeDevice?.name.toString() + + callStatusUI.isMuted = isMuted + + callStatusUI.audioDevices = audioDevices + + when (callState) { + CallState.INCOMING -> callStatusUI.callState = "Incoming Call" + CallState.OUTGOING -> callStatusUI.callState = "Dialing Out Call" + CallState.INCALL -> callStatusUI.callState = "In Call" + else -> { + callStatusUI.callState = "No Call" + } + } + + return callStatusUI + } + + enum class CallState { + NOCALL, + INCOMING, + OUTGOING, + INCALL + } + + init { + var capabilities: @CallsManager.Companion.Capability Int = + CallsManager.CAPABILITY_BASELINE or CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING + callsManager.registerAppWithTelecom(capabilities) + } + + fun makeOutGoingCall() { + callState.value = CallState.OUTGOING + makeCall(OUTGOING_CALL_ATTRIBUTES) + } + + fun fakeIncomingCall() { + makeCall(INCOMING_CALL_ATTRIBUTES) + } + + private fun makeCall(callAttributes: CallAttributesCompat) { + + CoroutineScope(Dispatchers.Unconfined).launch { + val coroutineScopes = this + callsManager.addCall(callAttributes) { + callControlScope = this + + setCallback(callControlCallback) + + availableEndpoints + .onEach { viewModel.availableAudioRoutes.value = it } + .launchIn(coroutineScopes) + + currentCallEndpoint + .onEach { viewModel.activeAudioRoute.value = it } + .launchIn(coroutineScope) + + isMuted + .onEach { viewModel.isMuted.value = it } + .launchIn(coroutineScope) + + /*availableEndpoint = + combine( + availableEndpoints, + currentCallEndpoint, + ) { availableDevices: List, activeDevice: CallEndpointCompat -> + availableDevices.map { + EndPointUI(isActive = activeDevice.name == it.name, it) + } + }*/ + + /* callStatus = combine( + availableEndpoints, + currentCallEndpoint, + callState + ) { availableDevices: List, activeDevice: CallEndpointCompat, callState: CallState -> + + //getCallStatus(availableDevices, activeDevice, false, callState) + }*/ + + + } + + if (callAttributes.direction == CallAttributesCompat.DIRECTION_INCOMING) { + onAnswerCall() + } else { + onCallActive() + } + + //this will start foreground service + //callNotificationSource.postOnGoingCall() + } + } + + fun onAnswerCall() { + coroutineScope.launch { + callControlScope?.let { callControlScope -> + if (callControlScope.answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL)) { + startCall() + } else { + //todo update error state + callState.value = CallState.NOCALL + } + } + } + } + + @RequiresApi(Build.VERSION_CODES.M) + fun onRejectCall() { + coroutineScope.launch { + callControlScope?.let { + it.disconnect(DisconnectCause(DisconnectCause.REJECTED)) + callState.value = CallState.NOCALL + } + } + } + + suspend fun onCallActive() { + callControlScope?.let { + if (it.setActive()) { + startCall() + } + } + } + + fun onToggleCallHold() { + coroutineScope.launch { + callControlScope?.let { + if ( viewModel.isActive.value) { + if (it.setInactive()) { + holdCall() + } + } else { + if (it.setActive()) { + startCall() + } + } + } + } + } + + @SuppressLint("NewApi") + fun OnHangUp() { + coroutineScope.launch { + callControlScope?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) + endCall() + } + } + + fun postIncomingcallNotification() { + //callNotificationSource.postIncomingCall() + } + + private fun startCall() { + + fakeCallSession.startAudioLoop() + viewModel.isActive.update { true } + viewModel.currentCallState.update { CallState.INCALL } + } + + private fun endCall() { + fakeCallSession.stopAudioLoop() + //callNotificationSource.onCancelNotification() + viewModel.isActive.update { false } + callStatus = + callState.map { callState -> + getCallStatus(emptyList(), null, null, callState) + } + viewModel.currentCallState.update { CallState.NOCALL } + viewModel.activeAudioRoute.update { null } + viewModel.availableAudioRoutes.update { emptyList() } + } + + private fun holdCall() { + fakeCallSession.stopAudioLoop() + viewModel.isActive.update { false } + viewModel.currentCallState.update { CallState.INCALL } + } + + fun setEndpoint(callEndpoint: CallEndpointCompat) { + coroutineScope.launch { + callControlScope?.requestEndpointChange(callEndpoint) + } + } + + private fun hasError(message: String) { + + } + + fun toggleMute(b: Boolean) { + + } + + fun toggleCallHold(b: Boolean) { + + } + + private val callControlCallback = object : CallControlCallback { + override suspend fun onSetActive(): Boolean { + startCall() + return true + } + + override suspend fun onSetInactive(): Boolean { + holdCall() + return true + } + + override suspend fun onAnswer(callType: Int): Boolean { + startCall() + return true + } + + override suspend fun onDisconnect(disconnectCause: DisconnectCause): Boolean { + endCall() + return true + } + } +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt new file mode 100644 index 00000000..a4b35267 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt @@ -0,0 +1,190 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.example.platform.connectivity.telecom + +import android.Manifest +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.example.platform.connectivity.telecom.screen.CallStatusUI +import com.example.platform.connectivity.telecom.screen.CallStatusWidget +import com.example.platform.connectivity.telecom.screen.DialerScreen +import com.example.platform.connectivity.telecom.screen.IncallScreen +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.rememberPermissionState +import com.google.android.catalog.framework.annotations.Sample + +@OptIn(ExperimentalPermissionsApi::class) +@Sample( + name = "TelecomSample", + description = "TODO: Add description", +) +class TelecomSample: ComponentActivity() { + + companion object { + lateinit var callViewModel: TelecomManager + } + + + @OptIn(ExperimentalPermissionsApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + callViewModel = TelecomManager(this, VoipViewModel()) + + setContent { + MaterialTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + // We should be using make_own_call permissions but this requires + // implementation of the telecom API to work correctly. + // Please see telecom example for full implementation + val multiplePermissionsState = + rememberMultiplePermissionsState( + listOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.MANAGE_OWN_CALLS, + ), + ) + + if (multiplePermissionsState.allPermissionsGranted) { + EntryPoint() + } else { + PermissionWidget(multiplePermissionsState) + } + } + } + } + } +} + +@Preview +@Composable +fun EntryPoint() { + Box(modifier = Modifier.fillMaxSize()) { + CallingStatus(TelecomSample.Companion.callViewModel) + Box(modifier = Modifier.align(Alignment.BottomCenter)) { + CallingBottomBar(TelecomSample.Companion.callViewModel) + } + } +} + +@Composable +fun CallingStatus(callViewModel: TelecomManager){ + + CallStatusWidget(callViewModel.viewModel) +} + +@Composable +fun CallingBottomBar(callViewModel: TelecomManager){ + + val callScreenState by callViewModel.viewModel.currentCallState.collectAsState() + + when(callScreenState){ + TelecomManager.CallState.INCALL -> { IncallScreen(callViewModel) } + TelecomManager.CallState.INCOMING -> { IncallScreen(callViewModel) } + TelecomManager.CallState.OUTGOING -> { OutgoingCall() } + else -> { DialerScreen(callViewModel) } + } +} + +@Composable +fun OutgoingCall(){ + Text( + text = "Dialing out...", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun PermissionWidget(permissionsState: MultiplePermissionsState) { + var showRationale by remember(permissionsState) { + mutableStateOf(false) + } + + if (showRationale) { + AlertDialog( + onDismissRequest = { showRationale = false }, + title = { + Text(text = "") + }, + text = { + Text(text = "") + }, + confirmButton = { + TextButton( + onClick = { + permissionsState.launchMultiplePermissionRequest() + }, + ) { + Text("Continue") + } + }, + dismissButton = { + TextButton( + onClick = { + showRationale = false + }, + ) { + Text("Dismiss") + } + }, + ) + } + + Button( + onClick = { + if (permissionsState.shouldShowRationale) { + showRationale = true + } else { + permissionsState.launchMultiplePermissionRequest() + } + }, + ) { + Text(text = "Grant Permission") + } +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt new file mode 100644 index 00000000..abc558f9 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import androidx.core.telecom.CallEndpointCompat +import kotlinx.coroutines.flow.MutableStateFlow + +class VoipViewModel { + val isMuted = MutableStateFlow(false) + val isActive = MutableStateFlow(false) + val activeAudioRoute : MutableStateFlow = MutableStateFlow(null) + val availableAudioRoutes : MutableStateFlow> = MutableStateFlow(emptyList()) + val currentCallState = MutableStateFlow(TelecomManager.CallState.NOCALL) + + var CallerName = "Jane Doe" +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt new file mode 100644 index 00000000..29c63ad2 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.telecom.CallEndpointCompat +import com.example.platform.connectivity.telecom.TelecomManager +import com.example.platform.connectivity.telecom.VoipViewModel + +class CallStatusUI { + var caller: String? = null + var isActive: Boolean = false + var currentAudioDevice: String? = null + var isMuted: Boolean? = false + var callState: String? = null + var audioDevices : List = emptyList() +} + +class EndPointUI(var isActive: Boolean, var callEndpoint: CallEndpointCompat) + +@Preview +@Composable +fun CallStatusWidget() { + val tempCaller = CallStatusUI() + tempCaller.caller = "Luke Hopkins" + tempCaller.isActive = false + tempCaller.currentAudioDevice = "Speaker" + tempCaller.isMuted = false + tempCaller.callState = "Not in call" + + // CallStatusWidget(tempCaller) +} + +@Composable +fun CallStatusWidget(callViewModel: VoipViewModel) { + + val callState by callViewModel.currentCallState.collectAsState() + val callStatus = when(callState) + { + TelecomManager.CallState.INCALL -> "In Call" + TelecomManager.CallState.INCOMING -> "Incoming Call" + TelecomManager.CallState.OUTGOING -> "Outgoing Call" + else -> "No Call" + } + + val activeDeviceName by callViewModel.activeAudioRoute.collectAsState() + val isMuted by callViewModel.isMuted.collectAsState() + val isActive by callViewModel.isActive.collectAsState() + val activeDevices by callViewModel.availableAudioRoutes.collectAsState() + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) { + Column(Modifier.padding(30.dp)) { + Text( + text = String.format("Call Status: %s", callStatus), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = String.format("Audio Device: %s", activeDeviceName?.name), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = String.format("Mute State: %b", isMuted), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = String.format("Active State: %b", isActive), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = String.format("Caller: %s", callViewModel.CallerName), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + + activeDevices.forEach{ + Text( + text = String.format("Caller: %s", it.name), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium, + ) + } + } + } +} + diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt new file mode 100644 index 00000000..5891d5ad --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.screen + +import android.R +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import com.example.platform.connectivity.telecom.TelecomManager + +@Composable +fun DialerScreen(callViewModel: TelecomManager) { + + DialerBottomBar({callViewModel.makeOutGoingCall() }, { callViewModel.fakeIncomingCall()}) +} + +@Composable +fun DialerBottomBar( + onOutgoingCall: () -> Unit, + onIncomingCall: () -> Unit, +) { + NavigationBar { + NavigationBarItem( + icon = { + Icon( + painter = painterResource(id = R.drawable.sym_call_outgoing), + contentDescription = "Outgoing Call" + ) + }, + label = { Text("Outgoing Call") }, + selected = false, + onClick = onOutgoingCall + ) + NavigationBarItem( + icon = { + Icon( + painter = painterResource(id = R.drawable.sym_call_incoming), + contentDescription = "Incoming Call" + ) + }, + label = { Text("Incoming Call") }, + selected = false, + onClick = onIncomingCall + ) + } +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt new file mode 100644 index 00000000..a5d72d42 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.screen + +import android.R +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledIconToggleButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.core.telecom.CallEndpointCompat +import com.example.platform.connectivity.telecom.TelecomManager + +@Composable +fun IncallScreen(callViewModel: TelecomManager) { + val callEndPoints by callViewModel.viewModel.availableAudioRoutes.collectAsState() + val muteState by callViewModel.viewModel.isMuted.collectAsState() + + IncallBottomBar( + callEndPoints, + muteState, + callViewModel::toggleMute, + callViewModel::toggleCallHold, + { callViewModel.OnHangUp() }, + callViewModel::setEndpoint + ) +} + +@Composable +fun IncallBottomBar( + endPoints: List, + muteState: Boolean, + onMuteChanged: (Boolean) -> Unit, + onHoldCall: (Boolean) -> Unit, + onHangUp: () -> Unit, + onAudioDeviceSelected: (CallEndpointCompat) -> Unit, +) { + + var audioDeviceWidgetState by remember { mutableStateOf(false) } + + BottomAppBar( + actions = { + ToggleButton( + R.drawable.arrow_down_float, + R.drawable.arrow_down_float, + muteState, + onMuteChanged, + ) + Box { + IconButton(onClick = { audioDeviceWidgetState = !audioDeviceWidgetState }) { + Icon( + painter = painterResource(id = R.drawable.arrow_down_float), + contentDescription = "Localized description", + ) + } + DropdownMenu( + expanded = audioDeviceWidgetState, + onDismissRequest = { audioDeviceWidgetState = false }, + ) { + LazyColumn { + items(endPoints) { item: CallEndpointCompat -> + CallEndPointItem( + endPointUI = item, + onDeviceSelected = onAudioDeviceSelected, + ) + } + } + } + } + ToggleButton( + R.drawable.arrow_down_float, + R.drawable.arrow_down_float, + muteState, + onHoldCall, + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onHangUp, + containerColor = Color.Red, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + ) { + Icon( + painter = painterResource(id = R.drawable.arrow_down_float), + "Localized description", + ) + } + }, + ) +} + + +@Composable +fun ToggleButton( + positiveResID: Int, + negativeResID: Int, + toggleState: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + FilledIconToggleButton(checked = toggleState, onCheckedChange = onCheckedChange) { + if (toggleState) { + Icon(painter = painterResource(id = positiveResID), "Switch On") + } else { + Icon(painter = painterResource(id = negativeResID), "Switch Off") + } + } +} + +/** + * Displays the audio device with Icon and Text + */ +@SuppressLint("NewApi") +@Composable +private fun CallEndPointItem( + endPointUI: CallEndpointCompat, + onDeviceSelected: (CallEndpointCompat) -> Unit, +) { + DropdownMenuItem( + text = { Text(endPointUI.name.toString()) }, + onClick = { onDeviceSelected(endPointUI) }, + leadingIcon = { + Icon( + Icons.Outlined.Email, + contentDescription = null, + ) + }, + trailingIcon = { Text("F11", textAlign = TextAlign.Center) }, + ) +} \ No newline at end of file From 42ac06baf4bc9c94d977f514abec65f6b9d70fbf Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Fri, 26 May 2023 03:57:53 +0000 Subject: [PATCH 05/41] Audio Devices UI User can not select from different audio devices --- .../connectivity/telecom/TelecomManager.kt | 98 +++---------------- .../telecom/screen/IncallScreen.kt | 24 ++--- 2 files changed, 24 insertions(+), 98 deletions(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt index 71040a92..b414663a 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt @@ -21,7 +21,6 @@ import android.content.Context import android.net.Uri import android.os.Build import android.telecom.DisconnectCause -import android.util.Log import androidx.annotation.RequiresApi import androidx.core.telecom.CallAttributesCompat import androidx.core.telecom.CallControlCallback @@ -29,16 +28,9 @@ import androidx.core.telecom.CallControlScope import androidx.core.telecom.CallEndpointCompat import androidx.core.telecom.CallsManager import com.example.platform.connectivity.audio.datasource.AudioLoopSource -import com.example.platform.connectivity.telecom.screen.CallStatusUI -import com.example.platform.connectivity.telecom.screen.EndPointUI import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -80,49 +72,9 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) var callControlScope: CallControlScope? = null var fakeCallSession = AudioLoopSource() - private val callNotificationSource = CallNotificationSource(context) + // private val callNotificationSource = CallNotificationSource(context) private val coroutineScope = CoroutineScope(Dispatchers.IO) var callsManager = CallsManager(context) - val callState = MutableStateFlow(CallState.NOCALL) - - - @SuppressLint("NewApi") - var availableEndpoint: Flow> = emptyFlow() - - var callStatus: Flow = - callState.map { callState -> - - getCallStatus(emptyList(), null, null, callState) - } - - - private fun getCallStatus( - audioDevices: List, - activeDevice: CallEndpointCompat?, - isMuted: Boolean?, - callState: CallState, - ): CallStatusUI { - var callStatusUI = CallStatusUI() - callStatusUI.caller = "John Doe" - // callStatusUI.isActive = isActive - - callStatusUI.currentAudioDevice = activeDevice?.name.toString() - - callStatusUI.isMuted = isMuted - - callStatusUI.audioDevices = audioDevices - - when (callState) { - CallState.INCOMING -> callStatusUI.callState = "Incoming Call" - CallState.OUTGOING -> callStatusUI.callState = "Dialing Out Call" - CallState.INCALL -> callStatusUI.callState = "In Call" - else -> { - callStatusUI.callState = "No Call" - } - } - - return callStatusUI - } enum class CallState { NOCALL, @@ -138,7 +90,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } fun makeOutGoingCall() { - callState.value = CallState.OUTGOING + viewModel.currentCallState.update { CallState.OUTGOING } makeCall(OUTGOING_CALL_ATTRIBUTES) } @@ -155,7 +107,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) setCallback(callControlCallback) - availableEndpoints + availableEndpoints .onEach { viewModel.availableAudioRoutes.value = it } .launchIn(coroutineScopes) @@ -166,27 +118,6 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) isMuted .onEach { viewModel.isMuted.value = it } .launchIn(coroutineScope) - - /*availableEndpoint = - combine( - availableEndpoints, - currentCallEndpoint, - ) { availableDevices: List, activeDevice: CallEndpointCompat -> - availableDevices.map { - EndPointUI(isActive = activeDevice.name == it.name, it) - } - }*/ - - /* callStatus = combine( - availableEndpoints, - currentCallEndpoint, - callState - ) { availableDevices: List, activeDevice: CallEndpointCompat, callState: CallState -> - - //getCallStatus(availableDevices, activeDevice, false, callState) - }*/ - - } if (callAttributes.direction == CallAttributesCompat.DIRECTION_INCOMING) { @@ -207,7 +138,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) startCall() } else { //todo update error state - callState.value = CallState.NOCALL + endCall() } } } @@ -218,7 +149,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) coroutineScope.launch { callControlScope?.let { it.disconnect(DisconnectCause(DisconnectCause.REJECTED)) - callState.value = CallState.NOCALL + endCall() } } } @@ -231,14 +162,14 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } } - fun onToggleCallHold() { + fun toggleCallHold(b: Boolean) { coroutineScope.launch { callControlScope?.let { - if ( viewModel.isActive.value) { + if (!b) { if (it.setInactive()) { holdCall() - } - } else { + } + } else { if (it.setActive()) { startCall() } @@ -260,7 +191,6 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } private fun startCall() { - fakeCallSession.startAudioLoop() viewModel.isActive.update { true } viewModel.currentCallState.update { CallState.INCALL } @@ -270,10 +200,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) fakeCallSession.stopAudioLoop() //callNotificationSource.onCancelNotification() viewModel.isActive.update { false } - callStatus = - callState.map { callState -> - getCallStatus(emptyList(), null, null, callState) - } + viewModel.currentCallState.update { CallState.NOCALL } viewModel.activeAudioRoute.update { null } viewModel.availableAudioRoutes.update { emptyList() } @@ -296,12 +223,9 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } fun toggleMute(b: Boolean) { - + viewModel.isMuted.update { !viewModel.isMuted.value } } - fun toggleCallHold(b: Boolean) { - - } private val callControlCallback = object : CallControlCallback { override suspend fun onSetActive(): Boolean { diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt index a5d72d42..00dba659 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Phone import androidx.compose.material3.BottomAppBar import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -48,6 +49,7 @@ import com.example.platform.connectivity.telecom.TelecomManager fun IncallScreen(callViewModel: TelecomManager) { val callEndPoints by callViewModel.viewModel.availableAudioRoutes.collectAsState() val muteState by callViewModel.viewModel.isMuted.collectAsState() + val activeState by callViewModel.viewModel.isActive.collectAsState() IncallBottomBar( callEndPoints, @@ -55,6 +57,7 @@ fun IncallScreen(callViewModel: TelecomManager) { callViewModel::toggleMute, callViewModel::toggleCallHold, { callViewModel.OnHangUp() }, + activeState, callViewModel::setEndpoint ) } @@ -66,6 +69,7 @@ fun IncallBottomBar( onMuteChanged: (Boolean) -> Unit, onHoldCall: (Boolean) -> Unit, onHangUp: () -> Unit, + activeState: Boolean, onAudioDeviceSelected: (CallEndpointCompat) -> Unit, ) { @@ -90,20 +94,18 @@ fun IncallBottomBar( expanded = audioDeviceWidgetState, onDismissRequest = { audioDeviceWidgetState = false }, ) { - LazyColumn { - items(endPoints) { item: CallEndpointCompat -> - CallEndPointItem( - endPointUI = item, - onDeviceSelected = onAudioDeviceSelected, - ) - } + endPoints.forEach{ + CallEndPointItem( + endPointUI = it, + onDeviceSelected = onAudioDeviceSelected, + ) } } } ToggleButton( R.drawable.arrow_down_float, R.drawable.arrow_down_float, - muteState, + activeState, onHoldCall, ) }, @@ -152,11 +154,11 @@ private fun CallEndPointItem( text = { Text(endPointUI.name.toString()) }, onClick = { onDeviceSelected(endPointUI) }, leadingIcon = { + Icon( - Icons.Outlined.Email, + Icons.Outlined.Phone, contentDescription = null, ) - }, - trailingIcon = { Text("F11", textAlign = TextAlign.Center) }, + } ) } \ No newline at end of file From 68d7b8741f22559ffa09b1b49b05f39a97a1c058 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Fri, 26 May 2023 19:41:08 +0000 Subject: [PATCH 06/41] Outgoing Call Complete User flow complete for incoming call --- .../connectivity/telecom/TelecomManager.kt | 65 ++++++-------- .../connectivity/telecom/TelecomSample.kt | 20 ++--- .../connectivity/telecom/VoipViewModel.kt | 87 ++++++++++++++++++- .../telecom/screen/DialerScreen.kt | 19 ++-- .../telecom/screen/IncallScreen.kt | 31 +++---- .../src/main/res/drawable/call_end.xml | 9 ++ .../telecom/src/main/res/drawable/mic_off.xml | 9 ++ .../telecom/src/main/res/drawable/mic_on.xml | 9 ++ .../telecom/src/main/res/drawable/speaker.xml | 4 + 9 files changed, 180 insertions(+), 73 deletions(-) create mode 100644 samples/connectivity/telecom/src/main/res/drawable/call_end.xml create mode 100644 samples/connectivity/telecom/src/main/res/drawable/mic_off.xml create mode 100644 samples/connectivity/telecom/src/main/res/drawable/mic_on.xml create mode 100644 samples/connectivity/telecom/src/main/res/drawable/speaker.xml diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt index b414663a..67527777 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt @@ -72,8 +72,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) var callControlScope: CallControlScope? = null var fakeCallSession = AudioLoopSource() - // private val callNotificationSource = CallNotificationSource(context) - private val coroutineScope = CoroutineScope(Dispatchers.IO) + private val coroutineScope = CoroutineScope(Dispatchers.Unconfined) var callsManager = CallsManager(context) enum class CallState { @@ -90,18 +89,16 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } fun makeOutGoingCall() { - viewModel.currentCallState.update { CallState.OUTGOING } makeCall(OUTGOING_CALL_ATTRIBUTES) } - fun fakeIncomingCall() { + fun makeIncomingCall() { makeCall(INCOMING_CALL_ATTRIBUTES) } private fun makeCall(callAttributes: CallAttributesCompat) { - CoroutineScope(Dispatchers.Unconfined).launch { - val coroutineScopes = this + coroutineScope.launch { callsManager.addCall(callAttributes) { callControlScope = this @@ -109,7 +106,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) availableEndpoints .onEach { viewModel.availableAudioRoutes.value = it } - .launchIn(coroutineScopes) + .launchIn(coroutineScope) currentCallEndpoint .onEach { viewModel.activeAudioRoute.value = it } @@ -118,19 +115,26 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) isMuted .onEach { viewModel.isMuted.value = it } .launchIn(coroutineScope) - } - if (callAttributes.direction == CallAttributesCompat.DIRECTION_INCOMING) { - onAnswerCall() - } else { - onCallActive() + onCallReady(callAttributes.direction) } + //this will start foreground service //callNotificationSource.postOnGoingCall() } } + private fun onCallReady(callDirection: Int) { + coroutineScope.launch { + if (callDirection == CallAttributesCompat.DIRECTION_INCOMING) { + viewModel.onCallStateChanged(CallState.INCOMING) + } else { + viewModel.onCallStateChanged(CallState.OUTGOING) + } + } + } + fun onAnswerCall() { coroutineScope.launch { callControlScope?.let { callControlScope -> @@ -154,32 +158,27 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } } - suspend fun onCallActive() { + suspend fun setCallActive() : Boolean { callControlScope?.let { if (it.setActive()) { - startCall() + startCall() } } + return false } - fun toggleCallHold(b: Boolean) { - coroutineScope.launch { - callControlScope?.let { - if (!b) { - if (it.setInactive()) { - holdCall() - } - } else { - if (it.setActive()) { - startCall() - } - } + suspend fun setCallInActive() : Boolean { + callControlScope?.let { + if (it.setInactive()) { + holdCall() + return true } } + return false } @SuppressLint("NewApi") - fun OnHangUp() { + fun hangUp() { coroutineScope.launch { callControlScope?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) endCall() @@ -193,17 +192,13 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) private fun startCall() { fakeCallSession.startAudioLoop() viewModel.isActive.update { true } - viewModel.currentCallState.update { CallState.INCALL } + viewModel.onCallStateChanged(CallState.INCALL) } private fun endCall() { fakeCallSession.stopAudioLoop() - //callNotificationSource.onCancelNotification() viewModel.isActive.update { false } - - viewModel.currentCallState.update { CallState.NOCALL } - viewModel.activeAudioRoute.update { null } - viewModel.availableAudioRoutes.update { emptyList() } + viewModel.onCallStateChanged(CallState.NOCALL) } private fun holdCall() { @@ -218,10 +213,6 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) } } - private fun hasError(message: String) { - - } - fun toggleMute(b: Boolean) { viewModel.isMuted.update { !viewModel.isMuted.value } } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt index a4b35267..c57251d4 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt @@ -61,7 +61,7 @@ import com.google.android.catalog.framework.annotations.Sample class TelecomSample: ComponentActivity() { companion object { - lateinit var callViewModel: TelecomManager + lateinit var callViewModel: VoipViewModel } @@ -69,7 +69,7 @@ class TelecomSample: ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - callViewModel = TelecomManager(this, VoipViewModel()) + callViewModel = VoipViewModel(this) setContent { MaterialTheme { @@ -90,7 +90,7 @@ class TelecomSample: ComponentActivity() { ) if (multiplePermissionsState.allPermissionsGranted) { - EntryPoint() + EntryPoint(callViewModel) } else { PermissionWidget(multiplePermissionsState) } @@ -102,25 +102,25 @@ class TelecomSample: ComponentActivity() { @Preview @Composable -fun EntryPoint() { +fun EntryPoint(callViewModel: VoipViewModel) { Box(modifier = Modifier.fillMaxSize()) { - CallingStatus(TelecomSample.Companion.callViewModel) + CallingStatus(callViewModel) Box(modifier = Modifier.align(Alignment.BottomCenter)) { - CallingBottomBar(TelecomSample.Companion.callViewModel) + CallingBottomBar(callViewModel) } } } @Composable -fun CallingStatus(callViewModel: TelecomManager){ +fun CallingStatus(callViewModel: VoipViewModel){ - CallStatusWidget(callViewModel.viewModel) + CallStatusWidget(callViewModel) } @Composable -fun CallingBottomBar(callViewModel: TelecomManager){ +fun CallingBottomBar(callViewModel: VoipViewModel){ - val callScreenState by callViewModel.viewModel.currentCallState.collectAsState() + val callScreenState by callViewModel.currentCallState.collectAsState() when(callScreenState){ TelecomManager.CallState.INCALL -> { IncallScreen(callViewModel) } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt index abc558f9..51d6a755 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt @@ -16,10 +16,19 @@ package com.example.platform.connectivity.telecom +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import androidx.core.telecom.CallAttributesCompat import androidx.core.telecom.CallEndpointCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch -class VoipViewModel { +class VoipViewModel(context: Context): ViewModel(){ val isMuted = MutableStateFlow(false) val isActive = MutableStateFlow(false) val activeAudioRoute : MutableStateFlow = MutableStateFlow(null) @@ -27,4 +36,80 @@ class VoipViewModel { val currentCallState = MutableStateFlow(TelecomManager.CallState.NOCALL) var CallerName = "Jane Doe" + + private val telecomManager = TelecomManager(context, this) + + fun onMakeCall(callDirection: Int){ + when(callDirection){ + CallAttributesCompat.DIRECTION_INCOMING -> telecomManager.makeIncomingCall() + else -> telecomManager.makeOutGoingCall() + } + } + + fun toggleHoldCall(toggle: Boolean){ + viewModelScope.launch { + val hasError = when(toggle) { + + true -> telecomManager.setCallActive() + + false -> telecomManager.setCallInActive() + } + + } + } + + fun toggleMute(toggle: Boolean){ + telecomManager.toggleMute(toggle) + } + + fun answerCall(){ + telecomManager.onAnswerCall() + } + + + fun rejectCall(){ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + telecomManager.onRejectCall() + } + } + + fun disconnectCall(){ + telecomManager.hangUp() + } + + fun setEndpoint(callEndpointCompat: CallEndpointCompat) { + telecomManager.setEndpoint(callEndpointCompat) + } + fun onCallStateChanged(callState : TelecomManager.CallState){ + currentCallState.update { callState } + + when(callState){ + TelecomManager.CallState.OUTGOING -> {fakeDialingCall()} + TelecomManager.CallState.INCOMING -> {} + TelecomManager.CallState.INCALL -> {} + else -> { onDialerScreen() } + } + } + + /** + * Fake a dialing out Call + * Waits for 5 Seconds before setting the call to active + */ + private fun fakeDialingCall(){ + viewModelScope.launch { + delay(5000) + telecomManager.setCallActive() + } + } + + private fun onDialerScreen() { + isActive.update { false } + currentCallState.update { TelecomManager.CallState.NOCALL } + activeAudioRoute.update { null } + availableAudioRoutes.update { emptyList() } + } + + fun onErrorMessage(){ + + } } \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt index 5891d5ad..e975d309 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt @@ -23,12 +23,15 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.painterResource -import com.example.platform.connectivity.telecom.TelecomManager +import androidx.core.telecom.CallAttributesCompat +import com.example.platform.connectivity.telecom.VoipViewModel @Composable -fun DialerScreen(callViewModel: TelecomManager) { - - DialerBottomBar({callViewModel.makeOutGoingCall() }, { callViewModel.fakeIncomingCall()}) +fun DialerScreen(callViewModel: VoipViewModel) { + DialerBottomBar( + { callViewModel.onMakeCall(CallAttributesCompat.DIRECTION_OUTGOING) }, + { callViewModel.onMakeCall(CallAttributesCompat.DIRECTION_INCOMING) }, + ) } @Composable @@ -41,23 +44,23 @@ fun DialerBottomBar( icon = { Icon( painter = painterResource(id = R.drawable.sym_call_outgoing), - contentDescription = "Outgoing Call" + contentDescription = "Outgoing Call", ) }, label = { Text("Outgoing Call") }, selected = false, - onClick = onOutgoingCall + onClick = onOutgoingCall, ) NavigationBarItem( icon = { Icon( painter = painterResource(id = R.drawable.sym_call_incoming), - contentDescription = "Incoming Call" + contentDescription = "Incoming Call", ) }, label = { Text("Incoming Call") }, selected = false, - onClick = onIncomingCall + onClick = onIncomingCall, ) } } \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt index 00dba659..8a6a1776 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt @@ -19,10 +19,7 @@ package com.example.platform.connectivity.telecom.screen import android.R import android.annotation.SuppressLint import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.Phone import androidx.compose.material3.BottomAppBar import androidx.compose.material3.DropdownMenu @@ -41,22 +38,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign import androidx.core.telecom.CallEndpointCompat -import com.example.platform.connectivity.telecom.TelecomManager +import com.example.platform.connectivity.telecom.R.drawable +import com.example.platform.connectivity.telecom.VoipViewModel @Composable -fun IncallScreen(callViewModel: TelecomManager) { - val callEndPoints by callViewModel.viewModel.availableAudioRoutes.collectAsState() - val muteState by callViewModel.viewModel.isMuted.collectAsState() - val activeState by callViewModel.viewModel.isActive.collectAsState() +fun IncallScreen(callViewModel: VoipViewModel) { + val callEndPoints by callViewModel.availableAudioRoutes.collectAsState() + val muteState by callViewModel.isMuted.collectAsState() + val activeState by callViewModel.isActive.collectAsState() IncallBottomBar( callEndPoints, muteState, callViewModel::toggleMute, - callViewModel::toggleCallHold, - { callViewModel.OnHangUp() }, + callViewModel::toggleHoldCall, + { callViewModel.disconnectCall()}, activeState, callViewModel::setEndpoint ) @@ -78,15 +75,15 @@ fun IncallBottomBar( BottomAppBar( actions = { ToggleButton( - R.drawable.arrow_down_float, - R.drawable.arrow_down_float, + drawable.mic_off, + drawable.mic_on, muteState, onMuteChanged, ) Box { IconButton(onClick = { audioDeviceWidgetState = !audioDeviceWidgetState }) { Icon( - painter = painterResource(id = R.drawable.arrow_down_float), + painter = painterResource(id = drawable.speaker), contentDescription = "Localized description", ) } @@ -103,8 +100,8 @@ fun IncallBottomBar( } } ToggleButton( - R.drawable.arrow_down_float, - R.drawable.arrow_down_float, + R.drawable.ic_menu_call, + R.drawable.stat_sys_phone_call_on_hold, activeState, onHoldCall, ) @@ -116,7 +113,7 @@ fun IncallBottomBar( elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), ) { Icon( - painter = painterResource(id = R.drawable.arrow_down_float), + painter = painterResource(id = drawable.call_end), "Localized description", ) } diff --git a/samples/connectivity/telecom/src/main/res/drawable/call_end.xml b/samples/connectivity/telecom/src/main/res/drawable/call_end.xml new file mode 100644 index 00000000..a3d3bc0f --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/call_end.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/connectivity/telecom/src/main/res/drawable/mic_off.xml b/samples/connectivity/telecom/src/main/res/drawable/mic_off.xml new file mode 100644 index 00000000..31632df3 --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/mic_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/connectivity/telecom/src/main/res/drawable/mic_on.xml b/samples/connectivity/telecom/src/main/res/drawable/mic_on.xml new file mode 100644 index 00000000..1328b565 --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/mic_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/samples/connectivity/telecom/src/main/res/drawable/speaker.xml b/samples/connectivity/telecom/src/main/res/drawable/speaker.xml new file mode 100644 index 00000000..6112ada9 --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/speaker.xml @@ -0,0 +1,4 @@ + + + From 21a4f36b1097d6749c60e7903c269ca0f4e930d0 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Fri, 26 May 2023 22:45:55 +0000 Subject: [PATCH 07/41] Add Call Notitifications Add Call Notitifications --- .../callnotification/NotificationSource.kt | 8 +- samples/connectivity/telecom/build.gradle.kts | 1 + .../telecom/src/main/AndroidManifest.xml | 4 + .../connectivity/telecom/TelecomSample.kt | 78 +++++++++++++++---- 4 files changed, 73 insertions(+), 18 deletions(-) diff --git a/samples/connectivity/callnotification/src/main/java/com/example/platform/connectivity/callnotification/NotificationSource.kt b/samples/connectivity/callnotification/src/main/java/com/example/platform/connectivity/callnotification/NotificationSource.kt index ab76d997..ec427208 100644 --- a/samples/connectivity/callnotification/src/main/java/com/example/platform/connectivity/callnotification/NotificationSource.kt +++ b/samples/connectivity/callnotification/src/main/java/com/example/platform/connectivity/callnotification/NotificationSource.kt @@ -46,7 +46,10 @@ class NotificationSource( const val ChannelId = "1234" const val ChannelIntID = 1234 + const val ChannelOnGoingIntID = 1233 + const val ChannelOnGoingID = "1233" /* + Notification state sent via in intent to inform receiving broadcast what action the user wants to take */ enum class NotificationState(val value: Int) { @@ -67,7 +70,7 @@ class NotificationSource( //Channel for on going call, this has low importance so that the notification is not always shown @SuppressLint("NewApi") private val notificationChannelOngoing = NotificationChannel( - ChannelId, "Call Notifications", + ChannelOnGoingID, "Call Notifications", NotificationManager.IMPORTANCE_LOW, ) @@ -186,7 +189,8 @@ class NotificationSource( cancelCallIntent, PendingIntent.FLAG_IMMUTABLE, ) - return NotificationCompat.Builder(context, ChannelId) + + return NotificationCompat.Builder(context, ChannelOnGoingID) .setSmallIcon(R.drawable.ic_dialog_dialer) .setFullScreenIntent(pendingIntent, false) .setOngoing(true) diff --git a/samples/connectivity/telecom/build.gradle.kts b/samples/connectivity/telecom/build.gradle.kts index 96d6134a..5b23a5a3 100644 --- a/samples/connectivity/telecom/build.gradle.kts +++ b/samples/connectivity/telecom/build.gradle.kts @@ -27,4 +27,5 @@ android { dependencies { implementation("androidx.core:core-telecom:1.0.0-alpha01") implementation(project(mapOf("path" to ":samples:connectivity:audio"))) + implementation(project(mapOf("path" to ":samples:connectivity:callnotification"))) } \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/AndroidManifest.xml b/samples/connectivity/telecom/src/main/AndroidManifest.xml index 1cce70a3..ea298d80 100644 --- a/samples/connectivity/telecom/src/main/AndroidManifest.xml +++ b/samples/connectivity/telecom/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ + + + \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt index c57251d4..22aee993 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt @@ -18,17 +18,18 @@ package com.example.platform.connectivity.telecom import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent import android.os.Bundle +import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -40,36 +41,52 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview -import com.example.platform.connectivity.telecom.screen.CallStatusUI +import com.example.platform.connectivity.callnotification.NotificationSource import com.example.platform.connectivity.telecom.screen.CallStatusWidget import com.example.platform.connectivity.telecom.screen.DialerScreen import com.example.platform.connectivity.telecom.screen.IncallScreen import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState -import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.google.accompanist.permissions.rememberPermissionState import com.google.android.catalog.framework.annotations.Sample +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @OptIn(ExperimentalPermissionsApi::class) @Sample( name = "TelecomSample", description = "TODO: Add description", ) -class TelecomSample: ComponentActivity() { +class TelecomSample : ComponentActivity() { companion object { + //Bad practise memory leak concerns however simple implementation for broadcast receiver lateinit var callViewModel: VoipViewModel } + lateinit var notificationSource: NotificationSource @OptIn(ExperimentalPermissionsApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) callViewModel = VoipViewModel(this) + notificationSource = NotificationSource(this, NotificationReceiver::class.java) + + + CoroutineScope(Dispatchers.Unconfined).launch { + callViewModel.currentCallState.collect { + when (it) { + TelecomManager.CallState.INCOMING -> notificationSource.postIncomingCall() + TelecomManager.CallState.INCALL -> notificationSource.postOnGoingCall() + else -> { + notificationSource.cancelNotification() + } + } + } + } setContent { MaterialTheme { @@ -98,6 +115,24 @@ class TelecomSample: ComponentActivity() { } } } + + class NotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + intent?.let { + val notificationStateValue = it.getIntExtra( + NotificationSource.NOTIFICATION_ACTION, + NotificationSource.Companion.NotificationState.CANCEL.ordinal, + ) + + when (notificationStateValue) { + NotificationSource.Companion.NotificationState.ANSWER.ordinal -> callViewModel.answerCall() + NotificationSource.Companion.NotificationState.REJECT.ordinal -> callViewModel.rejectCall() + else -> callViewModel.disconnectCall() + } + } + } + + } } @Preview @@ -112,26 +147,37 @@ fun EntryPoint(callViewModel: VoipViewModel) { } @Composable -fun CallingStatus(callViewModel: VoipViewModel){ +fun CallingStatus(callViewModel: VoipViewModel) { CallStatusWidget(callViewModel) } @Composable -fun CallingBottomBar(callViewModel: VoipViewModel){ +fun CallingBottomBar(callViewModel: VoipViewModel) { val callScreenState by callViewModel.currentCallState.collectAsState() - when(callScreenState){ - TelecomManager.CallState.INCALL -> { IncallScreen(callViewModel) } - TelecomManager.CallState.INCOMING -> { IncallScreen(callViewModel) } - TelecomManager.CallState.OUTGOING -> { OutgoingCall() } - else -> { DialerScreen(callViewModel) } + when (callScreenState) { + TelecomManager.CallState.INCALL -> { + IncallScreen(callViewModel) + } + + TelecomManager.CallState.INCOMING -> { + + } + + TelecomManager.CallState.OUTGOING -> { + OutgoingCall() + } + + else -> { + DialerScreen(callViewModel) + } } } @Composable -fun OutgoingCall(){ +fun OutgoingCall() { Text( text = "Dialing out...", color = MaterialTheme.colorScheme.primary, From 867b8ce0baa64eb0a42f975db7c15c2192ce3af1 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Wed, 31 May 2023 20:56:12 +0000 Subject: [PATCH 08/41] Init ViewModel and Permission Granted Init ViewModel and Permission Granted, AudioRecord not started when permission it not granted. --- samples/README.md | 2 +- .../connectivity/telecom/TelecomManager.kt | 5 +-- .../connectivity/telecom/TelecomSample.kt | 3 +- .../connectivity/telecom/VoipViewModel.kt | 45 +++++++++++-------- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/samples/README.md b/samples/README.md index fb1600ba..e210308c 100644 --- a/samples/README.md +++ b/samples/README.md @@ -65,6 +65,6 @@ Shows the recommended flow to request single runtime permissions - [Speakable Text](accessibility/src/main/java/com/example/platform/accessibility/SpeakableText.kt): The sample demonstrates the importance of having proper labels for - [TelecomSample](connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt): -TODO: Add description +Example application showing incoming and outgoing calls using the telecom jetpack library - [TextSpan](user-interface/text/src/main/java/com/example/platform/ui/text/TextSpan.kt): buildSpannedString is useful for quickly building a rich text. diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt index 67527777..2d1bceb3 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt @@ -118,10 +118,6 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) onCallReady(callAttributes.direction) } - - - //this will start foreground service - //callNotificationSource.postOnGoingCall() } } @@ -162,6 +158,7 @@ class TelecomManager(private val context: Context, val viewModel: VoipViewModel) callControlScope?.let { if (it.setActive()) { startCall() + return true } } return false diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt index 39010e0f..aeca4f32 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt @@ -70,7 +70,6 @@ class TelecomSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - callViewModel = VoipViewModel(this) notificationSource = NotificationSource(this, NotificationReceiver::class.java) @@ -105,6 +104,7 @@ class TelecomSample : ComponentActivity() { ) if (multiplePermissionsState.allPermissionsGranted) { + callViewModel = VoipViewModel(this) EntryPoint(callViewModel) } else { PermissionWidget(multiplePermissionsState) @@ -133,6 +133,7 @@ class TelecomSample : ComponentActivity() { } } + @Composable fun EntryPoint(callViewModel: VoipViewModel) { Box(modifier = Modifier.fillMaxSize()) { diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt index 51d6a755..b9948a09 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt @@ -16,7 +16,6 @@ package com.example.platform.connectivity.telecom -import android.annotation.SuppressLint import android.content.Context import android.os.Build import androidx.core.telecom.CallAttributesCompat @@ -28,27 +27,28 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class VoipViewModel(context: Context): ViewModel(){ +class VoipViewModel(context: Context) : ViewModel() { val isMuted = MutableStateFlow(false) val isActive = MutableStateFlow(false) - val activeAudioRoute : MutableStateFlow = MutableStateFlow(null) - val availableAudioRoutes : MutableStateFlow> = MutableStateFlow(emptyList()) + val activeAudioRoute: MutableStateFlow = MutableStateFlow(null) + val availableAudioRoutes: MutableStateFlow> = + MutableStateFlow(emptyList()) val currentCallState = MutableStateFlow(TelecomManager.CallState.NOCALL) var CallerName = "Jane Doe" private val telecomManager = TelecomManager(context, this) - fun onMakeCall(callDirection: Int){ - when(callDirection){ + fun onMakeCall(callDirection: Int) { + when (callDirection) { CallAttributesCompat.DIRECTION_INCOMING -> telecomManager.makeIncomingCall() else -> telecomManager.makeOutGoingCall() } } - fun toggleHoldCall(toggle: Boolean){ + fun toggleHoldCall(toggle: Boolean) { viewModelScope.launch { - val hasError = when(toggle) { + val hasError = when (toggle) { true -> telecomManager.setCallActive() @@ -58,36 +58,42 @@ class VoipViewModel(context: Context): ViewModel(){ } } - fun toggleMute(toggle: Boolean){ + fun toggleMute(toggle: Boolean) { telecomManager.toggleMute(toggle) } - fun answerCall(){ + fun answerCall() { telecomManager.onAnswerCall() } - fun rejectCall(){ + fun rejectCall() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { telecomManager.onRejectCall() } } - fun disconnectCall(){ + fun disconnectCall() { telecomManager.hangUp() } fun setEndpoint(callEndpointCompat: CallEndpointCompat) { telecomManager.setEndpoint(callEndpointCompat) } - fun onCallStateChanged(callState : TelecomManager.CallState){ + + fun onCallStateChanged(callState: TelecomManager.CallState) { currentCallState.update { callState } - when(callState){ - TelecomManager.CallState.OUTGOING -> {fakeDialingCall()} + when (callState) { + TelecomManager.CallState.OUTGOING -> { + fakeDialingCall() + } + TelecomManager.CallState.INCOMING -> {} TelecomManager.CallState.INCALL -> {} - else -> { onDialerScreen() } + else -> { + onDialerScreen() + } } } @@ -95,7 +101,7 @@ class VoipViewModel(context: Context): ViewModel(){ * Fake a dialing out Call * Waits for 5 Seconds before setting the call to active */ - private fun fakeDialingCall(){ + private fun fakeDialingCall() { viewModelScope.launch { delay(5000) telecomManager.setCallActive() @@ -109,7 +115,8 @@ class VoipViewModel(context: Context): ViewModel(){ availableAudioRoutes.update { emptyList() } } - fun onErrorMessage(){ + fun onErrorMessage() { } -} \ No newline at end of file +} + From 9e1db974bb83b83d5eb40e8543df58c558c1484e Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Thu, 1 Jun 2023 17:07:46 +0000 Subject: [PATCH 09/41] Init Audio Record after permissions are set Is AudioRecord is initilzed before permissions are granted audio recording with not start correctly on first run --- .../audio/datasource/AudioLoopSource.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt index 76b2cd08..333cf6bf 100644 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt @@ -58,16 +58,8 @@ class AudioLoopSource { AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, ) - - - @SuppressLint("MissingPermission") - val audioSampler = AudioRecord( - MediaRecorder.AudioSource.VOICE_COMMUNICATION, - sampleRate, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, - bufferSize, - ) + + lateinit var audioSampler : AudioRecord //Audio track for audio playback private var audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -103,7 +95,19 @@ class AudioLoopSource { /** * Gets buffer from Audio Recorder and loops back to the audio track */ + @SuppressLint("MissingPermission") fun startAudioLoop(): Boolean { + + if(audioSampler == null){ + audioSampler = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + sampleRate, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize, + ) + } + if (audioSampler.recordingState == AudioRecord.RECORDSTATE_RECORDING) { return false } From f587035c6f83f1a2d048348365a80621ec546812 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Thu, 1 Jun 2023 20:47:38 +0000 Subject: [PATCH 10/41] Audio Recording Bug Fix --- .../audio/datasource/AudioLoopSource.kt | 16 ++++++++-------- .../connectivity/telecom/TelecomSample.kt | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt index 333cf6bf..8c254408 100644 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt @@ -58,8 +58,8 @@ class AudioLoopSource { AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, ) - - lateinit var audioSampler : AudioRecord + + var audioSampler : AudioRecord? = null //Audio track for audio playback private var audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -108,15 +108,15 @@ class AudioLoopSource { ) } - if (audioSampler.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + if (audioSampler?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { return false } audioTrack.playbackRate = sampleRate job = coroutineScope.launch { - if (audioSampler.state == AudioRecord.STATE_INITIALIZED) { - audioSampler.startRecording() + if (audioSampler?.state == AudioRecord.STATE_INITIALIZED) { + audioSampler?.startRecording() } val data = ByteArray(bufferSize) @@ -126,7 +126,7 @@ class AudioLoopSource { while (isActive) { - val bytesRead = audioSampler.read(data, 0, bufferSize) + val bytesRead = audioSampler!!.read(data, 0, bufferSize) if (bytesRead > 0) { audioTrack.write(data, 0, bytesRead) @@ -143,7 +143,7 @@ class AudioLoopSource { fun stopAudioLoop() { job?.cancel("Stop Recording", null) isRecording.update { false } - audioSampler.stop() + audioSampler?.stop() audioTrack.stop() } @@ -153,7 +153,7 @@ class AudioLoopSource { fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { audioTrack.preferredDevice = audioDeviceInfo - audioSampler.preferredDevice = audioDeviceInfo + audioSampler?.preferredDevice = audioDeviceInfo } else { //Not required AudioManger will deal with routing in the PlatformAudioSource class } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt index aeca4f32..da1abf46 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt @@ -70,6 +70,7 @@ class TelecomSample : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + callViewModel = VoipViewModel(this) notificationSource = NotificationSource(this, NotificationReceiver::class.java) From f1385708d41e264cee2bbe27d433a0a9f4af30e8 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Tue, 13 Jun 2023 23:18:11 +0000 Subject: [PATCH 11/41] Notification Fix Notification Fix --- .../connectivity/telecom/TelecomSample.kt | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt index da1abf46..4384a3ee 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt @@ -73,20 +73,7 @@ class TelecomSample : ComponentActivity() { callViewModel = VoipViewModel(this) notificationSource = NotificationSource(this, NotificationReceiver::class.java) - - CoroutineScope(Dispatchers.Unconfined).launch { - callViewModel.currentCallState.collect { - when (it) { - TelecomManager.CallState.INCOMING -> notificationSource.postIncomingCall() - TelecomManager.CallState.INCALL -> notificationSource.postOnGoingCall() - else -> { - notificationSource.cancelNotification() - } - } - } - } - - setContent { + setContent { MaterialTheme { // A surface container using the 'background' color from the theme Surface( @@ -113,6 +100,18 @@ class TelecomSample : ComponentActivity() { } } } + + CoroutineScope(Dispatchers.Main).launch { + callViewModel.currentCallState.collect { + when (it) { + TelecomManager.CallState.INCOMING -> notificationSource.postIncomingCall() + TelecomManager.CallState.INCALL -> notificationSource.postOnGoingCall() + else -> { + notificationSource.cancelNotification() + } + } + } + } } class NotificationReceiver : BroadcastReceiver() { From 09d694dba9364231854fec54ec93eec3f2b21819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Wed, 5 Jul 2023 14:27:32 +0200 Subject: [PATCH 12/41] Revamp Telecom sample to follow session pattern The sample now follow the session pattern by keeping the scope in the call and forwarding actions via the flow. The UI has been simplified too Change-Id: I8aed73d66bdb981ae9a978c230a2bb4403cdafa3 --- app/build.gradle.kts | 2 +- .../SamplePlugin.kt | 3 + gradle/libs.versions.toml | 4 +- samples/README.md | 4 +- .../audio/datasource/AudioLoopSource.kt | 6 +- samples/connectivity/telecom/build.gradle.kts | 5 +- .../telecom/src/main/AndroidManifest.xml | 17 +- .../telecom/TelecomCallBroadcast.kt | 58 ++ .../telecom/TelecomCallNotificationManager.kt | 158 +++++ .../telecom/TelecomCallSampleActivity.kt | 568 ++++++++++++++++++ .../connectivity/telecom/TelecomManager.kt | 239 -------- .../connectivity/telecom/TelecomSample.kt | 235 -------- .../connectivity/telecom/VoipViewModel.kt | 122 ---- .../connectivity/telecom/model/TelecomCall.kt | 53 ++ .../telecom/model/TelecomCallAction.kt | 45 ++ .../telecom/model/TelecomCallRepository.kt | 335 +++++++++++ .../telecom/screen/CallStatusUI.kt | 88 --- .../telecom/screen/DialerScreen.kt | 66 -- .../telecom/screen/IncallScreen.kt | 161 ----- .../src/main/res/drawable/call_end.xml | 9 - .../main/res/drawable/ic_phone_paused_24.xml | 5 + .../main/res/drawable/ic_round_call_24.xml | 5 + .../telecom/src/main/res/drawable/mic_off.xml | 9 - .../telecom/src/main/res/drawable/mic_on.xml | 9 - .../telecom/src/main/res/drawable/speaker.xml | 4 - .../user-interface/haptics/build.gradle.kts | 4 - .../text/src/main/AndroidManifest.xml | 3 +- settings.gradle.kts | 3 + 28 files changed, 1252 insertions(+), 968 deletions(-) create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt delete mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt delete mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt delete mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt delete mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt delete mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt delete mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt delete mode 100644 samples/connectivity/telecom/src/main/res/drawable/call_end.xml create mode 100644 samples/connectivity/telecom/src/main/res/drawable/ic_phone_paused_24.xml create mode 100644 samples/connectivity/telecom/src/main/res/drawable/ic_round_call_24.xml delete mode 100644 samples/connectivity/telecom/src/main/res/drawable/mic_off.xml delete mode 100644 samples/connectivity/telecom/src/main/res/drawable/mic_on.xml delete mode 100644 samples/connectivity/telecom/src/main/res/drawable/speaker.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b36f2c19..a503be1b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,7 +30,7 @@ java { android { namespace = "com.example.platform.app" - compileSdk = 34 + compileSdkPreview = "UpsideDownCake" defaultConfig { applicationId = "com.example.platform.app" diff --git a/build-logic/src/main/kotlin/com.example.platform.plugin/SamplePlugin.kt b/build-logic/src/main/kotlin/com.example.platform.plugin/SamplePlugin.kt index 642ff248..9209d208 100644 --- a/build-logic/src/main/kotlin/com.example.platform.plugin/SamplePlugin.kt +++ b/build-logic/src/main/kotlin/com.example.platform.plugin/SamplePlugin.kt @@ -44,6 +44,7 @@ class SamplePlugin : Plugin { apply("org.jetbrains.kotlin.kapt") apply("com.google.devtools.ksp") apply("dagger.hilt.android.plugin") + apply("kotlin-parcelize") apply() } @@ -112,6 +113,8 @@ class SamplePlugin : Plugin { "implementation"(libs.findLibrary("androidx.lifecycle.viewmodel.compose").get()) "implementation"(libs.findLibrary("compose.ui.ui").get()) "implementation"(libs.findLibrary("compose.material3").get()) + "implementation"(libs.findLibrary("compose.material.iconsext").get()) + "implementation"(libs.findLibrary("coil.compose").get()) "implementation"(libs.findLibrary("coil.video").get()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d272be11..922b4551 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ accompanist = "0.28.0" androidx-datastore = "1.0.0" androidx-navigation = "2.6.0" androidx-window = "1.1.0" -androidGradlePlugin = "7.4.2" +androidGradlePlugin = "8.0.2" casa = "0.4.3" coil = "2.3.0" ksp = "1.8.21-1.0.11" @@ -26,7 +26,7 @@ compose-bom = "2023.06.01" composeCompiler = "1.4.7" hilt = "2.44.2" kotlin = "1.8.21" -kotlin-serialization = "1.5.0" +kotlin-serialization = "1.5.1" ktlint = "0.48.1" coroutines = "1.6.4" play-services-location = "21.0.1" diff --git a/samples/README.md b/samples/README.md index 08cfe0e8..8a9b0c39 100644 --- a/samples/README.md +++ b/samples/README.md @@ -76,8 +76,8 @@ This sample shows how to detect that the user capture the screen in Android 14 o Shows the recommended flow to request single runtime permissions - [Speakable Text](accessibility/src/main/java/com/example/platform/accessibility/SpeakableText.kt): The sample demonstrates the importance of having proper labels for -- [TelecomSample](connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt): -Example application showing incoming and outgoing calls using the telecom jetpack library +- [Telecom Call Sample](connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt): +A sample showcasing how to handle calls with the Jetpack Telecom API - [TextSpan](user-interface/text/src/main/java/com/example/platform/ui/text/TextSpan.kt): buildSpannedString is useful for quickly building a rich text. - [WindowInsetsAnimation](user-interface/window-insets/src/main/java/com/example/platform/ui/insets/WindowInsetsAnimation.kt): diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt index d46a53b0..9e2a9bb2 100644 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt @@ -59,7 +59,7 @@ class AudioLoopSource { AudioFormat.ENCODING_PCM_16BIT, ) - var audioSampler : AudioRecord? = null + var audioSampler: AudioRecord? = null //Audio track for audio playback private val audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -98,7 +98,7 @@ class AudioLoopSource { @SuppressLint("MissingPermission") fun startAudioLoop(): Boolean { - if(audioSampler == null){ + if (audioSampler == null) { audioSampler = AudioRecord( MediaRecorder.AudioSource.VOICE_COMMUNICATION, sampleRate, @@ -143,7 +143,7 @@ class AudioLoopSource { fun stopAudioLoop() { job?.cancel("Stop Recording", null) isRecording.update { false } - if (audioSampler.state == AudioRecord.STATE_INITIALIZED) { + if (audioSampler?.state == AudioRecord.STATE_INITIALIZED) { audioSampler?.stop() } audioTrack.stop() diff --git a/samples/connectivity/telecom/build.gradle.kts b/samples/connectivity/telecom/build.gradle.kts index e6b8c4ff..e24587d3 100644 --- a/samples/connectivity/telecom/build.gradle.kts +++ b/samples/connectivity/telecom/build.gradle.kts @@ -25,8 +25,7 @@ android { } dependencies { - implementation("androidx.core:core-telecom:1.0.0-alpha01") + implementation("androidx.core:core-telecom:1.0.0-SNAPSHOT") implementation(project(mapOf("path" to ":samples:connectivity:audio"))) implementation(project(mapOf("path" to ":samples:connectivity:callnotification"))) - -} \ No newline at end of file +} diff --git a/samples/connectivity/telecom/src/main/AndroidManifest.xml b/samples/connectivity/telecom/src/main/AndroidManifest.xml index 87bf6c5b..d2b15f05 100644 --- a/samples/connectivity/telecom/src/main/AndroidManifest.xml +++ b/samples/connectivity/telecom/src/main/AndroidManifest.xml @@ -17,26 +17,25 @@ + - - - - - + android:launchMode="singleInstance" + android:turnScreenOn="true" + android:showWhenLocked="true" + android:showOnLockScreen="true"> - \ No newline at end of file + diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt new file mode 100644 index 00000000..75a72aad --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallAction +import com.example.platform.connectivity.telecom.model.TelecomCallRepository + +/** + * A simple BroadcastReceiver that routes the call notification actions to the TelecomCallRepository + */ +@RequiresApi(Build.VERSION_CODES.O) +class TelecomCallBroadcast : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + Log.d("MPB", "onReceive: ${intent.extras}") + // Get the action or skip if none + val action = intent.getTelecomCallAction() ?: return + val repo = TelecomCallRepository.instance ?: TelecomCallRepository.create(context) + val call = repo.currentCall.value + + // If the call is still registered perform action + if (call is TelecomCall.Registered) { + call.processAction(action) + } + } + + private fun Intent.getTelecomCallAction() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra( + TelecomCallNotificationManager.TELECOM_NOTIFICATION_ACTION, + TelecomCallAction::class.java, + ) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(TelecomCallNotificationManager.TELECOM_NOTIFICATION_ACTION) + } +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt new file mode 100644 index 00000000..615ce68e --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import android.Manifest +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.os.Build +import android.telecom.DisconnectCause +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.content.PermissionChecker +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallAction + +@RequiresApi(Build.VERSION_CODES.O) +class TelecomCallNotificationManager(private val context: Context) { + internal companion object { + const val TELECOM_NOTIFICATION_ID = 200 + const val TELECOM_NOTIFICATION_ACTION = "telecom_action" + const val TELECOM_NOTIFICATION_CHANNEL_ID = "telecom_channel" + + private val ringToneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + } + + private val notificationManager: NotificationManagerCompat = + NotificationManagerCompat.from(context) + + fun updateCallNotification(call: TelecomCall) { + // If notifications are not granted, skip it. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && + PermissionChecker.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) != PermissionChecker.PERMISSION_GRANTED + ) { + return + } + + // Ensure that the channel is created + createNotificationChannel() + + // Update notification + when (call) { + TelecomCall.None, is TelecomCall.Unregistered -> cancelNotification() + is TelecomCall.Registered -> updateNotification(call) + } + } + + private fun updateNotification(call: TelecomCall.Registered) { + val caller = Person.Builder() + .setName(call.callAttributes.displayName) + .setUri(call.callAttributes.address.toString()) + .setImportant(true) + .build() + val contentIntent = PendingIntent.getActivity( + context, + 0, + Intent(context, TelecomCallSampleActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + // Define the call style based on the call state and set the right actions + val callStyle = if (call.isIncoming() && !call.isActive) { + NotificationCompat.CallStyle.forIncomingCall( + caller, + getPendingIntent( + TelecomCallAction.Disconnect( + DisconnectCause(DisconnectCause.REJECTED), + ), + ), + getPendingIntent(TelecomCallAction.Answer), + ) + } else { + NotificationCompat.CallStyle.forOngoingCall( + caller, + getPendingIntent( + TelecomCallAction.Disconnect( + DisconnectCause(DisconnectCause.LOCAL), + ), + ), + ) + } + + val builder = NotificationCompat.Builder(context, TELECOM_NOTIFICATION_CHANNEL_ID) + .setContentText("test") + .setContentIntent(contentIntent) + .setFullScreenIntent(contentIntent, true) + .setSmallIcon(R.drawable.ic_round_call_24) + .setOngoing(true) + .setStyle(callStyle) + + if (call.isIncoming()) { + builder.setSound(ringToneUri) + } + + // TODO figure out why custom actions are not working + if (call.isOnHold) { + builder.addAction( + R.drawable.ic_phone_paused_24, "Resume", + getPendingIntent( + TelecomCallAction.Activate, + ), + ) + } + + @Suppress("MissingPermission") + notificationManager.notify(TELECOM_NOTIFICATION_ID, builder.build()) + } + + private fun cancelNotification() { + notificationManager.cancel(TELECOM_NOTIFICATION_ID) + } + + private fun getPendingIntent(action: TelecomCallAction): PendingIntent { + val callIntent = Intent(context, TelecomCallBroadcast::class.java) + callIntent.putExtra( + TELECOM_NOTIFICATION_ACTION, + action, + ) + + return PendingIntent.getBroadcast( + context, + callIntent.hashCode(), + callIntent, + PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun createNotificationChannel() { + val name = "Telecom Channel" + val descriptionText = "Handles the notifications when receiving or doing a call" + val channel = NotificationChannelCompat.Builder( + TELECOM_NOTIFICATION_CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_HIGH, + ).setName(name).setDescription(descriptionText).build() + notificationManager.createNotificationChannel(channel) + } +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt new file mode 100644 index 00000000..2dab5701 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt @@ -0,0 +1,568 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import android.Manifest +import android.app.KeyguardManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.telecom.DisconnectCause +import android.util.Log +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BluetoothAudio +import androidx.compose.material.icons.rounded.BluetoothDisabled +import androidx.compose.material.icons.rounded.Call +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.MicOff +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PhoneForwarded +import androidx.compose.material.icons.rounded.PhonePaused +import androidx.compose.material.icons.rounded.VolumeUp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.getSystemService +import androidx.core.telecom.CallEndpointCompat +import com.example.platform.base.PermissionBox +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallAction +import com.example.platform.connectivity.telecom.model.TelecomCallRepository +import com.google.android.catalog.framework.annotations.Sample +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + + +@Sample( + name = "Telecom Call Sample", + description = "A sample showcasing how to handle calls with the Jetpack Telecom API", +) +@RequiresApi(Build.VERSION_CODES.O) +class TelecomCallSampleActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.d("MPB", "onCreate:") + setupCallActivity() + setContent { + MaterialTheme { + Surface( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + // We should be using make_own_call permissions but this requires + // implementation of the telecom API to work correctly. + // Please see telecom example for full implementation + val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + listOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.MANAGE_OWN_CALLS, + ) + } else { + listOf(Manifest.permission.RECORD_AUDIO) + } + val context = LocalContext.current + val repository = remember { + TelecomCallRepository.instance ?: TelecomCallRepository.create(context) + } + PermissionBox(permissions = permissions) { + CallerScreen(repository) + } + } + } + } + } + + /** + * Enable the calling activity to be shown in the lockscreen and dismiss the keyguard to enable + * users to answer without unblocking. + */ + private fun setupCallActivity() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON, + ) + } + + val keyguardManager = getSystemService() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && keyguardManager != null) { + keyguardManager.requestDismissKeyguard(this, null) + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun CallerScreen(repository: TelecomCallRepository) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Collect the current call state and update UI + val call by repository.currentCall.collectAsState() + + // If call goes unregistered inform user + if (call is TelecomCall.Unregistered) { + LaunchedEffect(true) { + Toast.makeText(context, "Call disconnected", Toast.LENGTH_SHORT).show() + } + } + + when (val newCall = call) { + is TelecomCall.Unregistered, TelecomCall.None -> { + // Show calling menu when there is no active call + NoCallScreen( + incomingCall = { + Toast.makeText(context, "Incoming call in 2 seconds", Toast.LENGTH_SHORT).show() + scope.launch(Dispatchers.IO) { + repository.registerCall( + displayName = "Alice", + address = Uri.parse(""), + isIncoming = true, + ) + } + }, + outgoingCall = { + scope.launch(Dispatchers.IO) { + repository.registerCall( + displayName = "Bob", + address = Uri.parse(""), + isIncoming = false, + ) + + // Faking that the other end is not picking it + delay(2000) + + // The other end answered, activate the call + (repository.currentCall.value as? TelecomCall.Registered)?.processAction( + TelecomCallAction.Activate, + ) + } + }, + ) + } + + is TelecomCall.Registered -> { + // Call screen only contains the logic to represent the values of the active call + // and process user input by calling the processAction of the active call. + CallScreen( + name = newCall.callAttributes.displayName.toString(), + info = newCall.callAttributes.address.toString(), + incoming = newCall.isIncoming(), + isActive = newCall.isActive, + isOnHold = newCall.isOnHold, + isMuted = newCall.isMuted, + currentEndpoint = newCall.currentCallEndpoint, + endpoints = newCall.availableCallEndpoints, + onCallAction = newCall::processAction, + ) + } + } +} + +@Composable +private fun NoCallScreen(incomingCall: () -> Unit, outgoingCall: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "No active call", style = MaterialTheme.typography.titleLarge) + Button(onClick = incomingCall) { + Text(text = "Receive fake call") + } + Button(onClick = outgoingCall) { + Text(text = "Make fake call") + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun CallScreen( + name: String, + info: String, + incoming: Boolean, + isActive: Boolean, + isOnHold: Boolean, + isMuted: Boolean, + currentEndpoint: CallEndpointCompat?, + endpoints: List, + onCallAction: (TelecomCallAction) -> Unit, +) { + var showTransferEndpoints by remember { + mutableStateOf(false) + } + val transferEndpoints = remember(endpoints) { + endpoints.filter { it.type == CallEndpointCompat.TYPE_STREAMING } + } + Column( + Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CallInfoCard(name, info, isActive) + if (incoming && !isActive) { + IncomingCallActions(onCallAction) + } else { + OngoingCallActions( + isActive, + isOnHold, + isMuted, + currentEndpoint, + endpoints, + onCallAction, + showTransferEndpoints, + ) + } + } + + // Show a picker when selecting to transfer a call + AnimatedVisibility(visible = showTransferEndpoints) { + TransferCallDialog( + showTransferEndpoints = showTransferEndpoints, + transferEndpoints = transferEndpoints, + onDismissRequest = { + showTransferEndpoints = false + }, + onCallAction = { + showTransferEndpoints = false + onCallAction(it) + }, + ) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun OngoingCallActions( + isActive: Boolean, + isOnHold: Boolean, + isMuted: Boolean, + currentEndpoint: CallEndpointCompat?, + endpoints: List, + onCallAction: (TelecomCallAction) -> Unit, + showTransferEndpoints: Boolean, +) { + var showTransferEndpoints1 = showTransferEndpoints + Column( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .shadow(1.dp) + .padding(26.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CallControls( + isActive = isActive, + isOnHold = isOnHold, + isMuted = isMuted, + endpointType = currentEndpoint?.type ?: CallEndpointCompat.TYPE_UNKNOWN, + availableTypes = endpoints.map { it.type }, + onCallAction = onCallAction, + onTransferCall = { showTransferEndpoints1 = true }, + ) + FloatingActionButton( + onClick = { + onCallAction( + TelecomCallAction.Disconnect( + DisconnectCause( + DisconnectCause.LOCAL, + ), + ), + ) + }, + containerColor = MaterialTheme.colorScheme.error, + ) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = null, + modifier = Modifier.rotate(90f), + ) + } + } +} + +@RequiresApi(Build.VERSION_CODES.M) +@Composable +private fun IncomingCallActions(onCallAction: (TelecomCallAction) -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(26.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + FloatingActionButton( + onClick = { + onCallAction( + TelecomCallAction.Disconnect( + DisconnectCause( + DisconnectCause.REJECTED, + ), + ), + ) + }, + containerColor = MaterialTheme.colorScheme.error, + ) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = null, + modifier = Modifier.rotate(90f), + ) + } + FloatingActionButton( + onClick = { + onCallAction( + TelecomCallAction.Answer, + ) + }, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon(imageVector = Icons.Rounded.Call, contentDescription = null) + } + } +} + +@Composable +private fun CallInfoCard(name: String, info: String, isActive: Boolean) { + Column( + Modifier + .fillMaxSize(0.5f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image(imageVector = Icons.Rounded.Person, contentDescription = null) + Text(text = name, style = MaterialTheme.typography.titleMedium) + Text(text = info, style = MaterialTheme.typography.bodyMedium) + + if (!isActive) { + Text(text = "Connecting...", style = MaterialTheme.typography.titleSmall) + } else { + Text(text = "Connected", style = MaterialTheme.typography.titleSmall) + } + + } +} + +@Composable +fun CallControls( + isActive: Boolean, + isOnHold: Boolean, + isMuted: Boolean, + endpointType: @CallEndpointCompat.Companion.EndpointType Int, + availableTypes: List<@CallEndpointCompat.Companion.EndpointType Int>, + onCallAction: (TelecomCallAction) -> Unit, + onTransferCall: () -> Unit, +) { + val isLocalCall = endpointType != CallEndpointCompat.TYPE_STREAMING + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + IconToggleButton( + checked = isMuted, + onCheckedChange = { + onCallAction(TelecomCallAction.Mute(it)) + }, + ) { + if (isMuted) { + Icon(imageVector = Icons.Rounded.MicOff, contentDescription = "Mic on") + } else { + Icon(imageVector = Icons.Rounded.Mic, contentDescription = "Mic off") + } + } + IconToggleButton( + checked = endpointType == CallEndpointCompat.TYPE_SPEAKER, + enabled = isLocalCall, + onCheckedChange = { selected -> + val type = if (selected) { + CallEndpointCompat.TYPE_SPEAKER + } else { + // Switch to either wired headset or earpiece + availableTypes.firstOrNull { it == CallEndpointCompat.TYPE_WIRED_HEADSET } + ?: CallEndpointCompat.TYPE_EARPIECE + } + onCallAction(TelecomCallAction.SwitchAudioType(type)) + }, + ) { + Icon(imageVector = Icons.Rounded.VolumeUp, contentDescription = "Toggle speaker") + } + if (availableTypes.contains(CallEndpointCompat.TYPE_BLUETOOTH)) { + IconToggleButton( + checked = endpointType == CallEndpointCompat.TYPE_BLUETOOTH, + enabled = isLocalCall, + onCheckedChange = { selected -> + val type = if (selected) { + CallEndpointCompat.TYPE_BLUETOOTH + } else { + // Switch to the default endpoint (as defined in TelecomCallRepo) + availableTypes.firstOrNull { it == CallEndpointCompat.TYPE_WIRED_HEADSET } + ?: CallEndpointCompat.TYPE_EARPIECE + } + onCallAction(TelecomCallAction.SwitchAudioType(type)) + }, + ) { + if (endpointType == CallEndpointCompat.TYPE_BLUETOOTH) { + Icon( + imageVector = Icons.Rounded.BluetoothAudio, + contentDescription = "Disable bluetooth", + ) + } else { + Icon( + imageVector = Icons.Rounded.BluetoothDisabled, + contentDescription = "Enable bluetooth", + ) + } + } + } + IconToggleButton( + enabled = isActive, + checked = isOnHold, + onCheckedChange = { + val action = if (it) { + TelecomCallAction.Hold + } else { + TelecomCallAction.Activate + } + onCallAction(action) + }, + ) { + Icon( + imageVector = Icons.Rounded.PhonePaused, + contentDescription = "Pause or resume call", + ) + } + + if (availableTypes.contains(CallEndpointCompat.TYPE_STREAMING)) { + IconToggleButton( + enabled = isActive, + checked = !isLocalCall, + onCheckedChange = { + if (it) { + onTransferCall() + } else { + // Switch back to the default audio type + onCallAction(TelecomCallAction.SwitchAudioType(CallEndpointCompat.TYPE_UNKNOWN)) + } + }, + ) { + Icon( + imageVector = Icons.Rounded.PhoneForwarded, + contentDescription = "Transfer call", + ) + } + } + } +} + + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun TransferCallDialog( + showTransferEndpoints: Boolean, + transferEndpoints: List, + onDismissRequest: () -> Unit, + onCallAction: (TelecomCallAction) -> Unit, +) { + var showTransferEndpoints1 = showTransferEndpoints + AlertDialog( + onDismissRequest = onDismissRequest, + ) { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column(Modifier.padding(16.dp)) { + Text(text = "Where to transfer the call?") + LazyColumn { + items(transferEndpoints) { + Text( + text = it.name.toString(), + modifier = Modifier + .fillMaxWidth() + .clickable { + onCallAction(TelecomCallAction.TransferCall(it.identifier)) + }, + ) + } + } + Row(horizontalArrangement = Arrangement.End) { + OutlinedButton(onClick = { showTransferEndpoints1 = false }) { + Text(text = "Dismiss") + } + } + } + } + } +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt deleted file mode 100644 index 2d1bceb3..00000000 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomManager.kt +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.platform.connectivity.telecom - -import android.annotation.SuppressLint -import android.content.Context -import android.net.Uri -import android.os.Build -import android.telecom.DisconnectCause -import androidx.annotation.RequiresApi -import androidx.core.telecom.CallAttributesCompat -import androidx.core.telecom.CallControlCallback -import androidx.core.telecom.CallControlScope -import androidx.core.telecom.CallEndpointCompat -import androidx.core.telecom.CallsManager -import com.example.platform.connectivity.audio.datasource.AudioLoopSource -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -class TelecomManager(private val context: Context, val viewModel: VoipViewModel) { - - companion object { - const val APP_SCHEME = "MyCustomScheme" - const val ALL_CALL_CAPABILITIES = (CallAttributesCompat.SUPPORTS_SET_INACTIVE - or CallAttributesCompat.SUPPORTS_STREAM or CallAttributesCompat.SUPPORTS_TRANSFER) - - // outgoing attributes constants - const val OUTGOING_NAME = "Darth Maul" - val OUTGOING_URI: Uri = Uri.fromParts(APP_SCHEME, "", "") - - // Define the minimal set of properties to start an outgoing call - var OUTGOING_CALL_ATTRIBUTES = CallAttributesCompat( - OUTGOING_NAME, - OUTGOING_URI, - CallAttributesCompat.DIRECTION_OUTGOING, - ALL_CALL_CAPABILITIES, - ) - - // incoming attributes constants - const val INCOMING_NAME = "Sundar Pichai" - val INCOMING_URI: Uri = Uri.fromParts(APP_SCHEME, "", "") - - // Define all possible properties for CallAttributes - val INCOMING_CALL_ATTRIBUTES = - CallAttributesCompat( - INCOMING_NAME, - INCOMING_URI, - CallAttributesCompat.DIRECTION_INCOMING, - CallAttributesCompat.CALL_TYPE_VIDEO_CALL, - ALL_CALL_CAPABILITIES, - ) - } - - var callControlScope: CallControlScope? = null - var fakeCallSession = AudioLoopSource() - - private val coroutineScope = CoroutineScope(Dispatchers.Unconfined) - var callsManager = CallsManager(context) - - enum class CallState { - NOCALL, - INCOMING, - OUTGOING, - INCALL - } - - init { - var capabilities: @CallsManager.Companion.Capability Int = - CallsManager.CAPABILITY_BASELINE or CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING - callsManager.registerAppWithTelecom(capabilities) - } - - fun makeOutGoingCall() { - makeCall(OUTGOING_CALL_ATTRIBUTES) - } - - fun makeIncomingCall() { - makeCall(INCOMING_CALL_ATTRIBUTES) - } - - private fun makeCall(callAttributes: CallAttributesCompat) { - - coroutineScope.launch { - callsManager.addCall(callAttributes) { - callControlScope = this - - setCallback(callControlCallback) - - availableEndpoints - .onEach { viewModel.availableAudioRoutes.value = it } - .launchIn(coroutineScope) - - currentCallEndpoint - .onEach { viewModel.activeAudioRoute.value = it } - .launchIn(coroutineScope) - - isMuted - .onEach { viewModel.isMuted.value = it } - .launchIn(coroutineScope) - - onCallReady(callAttributes.direction) - } - } - } - - private fun onCallReady(callDirection: Int) { - coroutineScope.launch { - if (callDirection == CallAttributesCompat.DIRECTION_INCOMING) { - viewModel.onCallStateChanged(CallState.INCOMING) - } else { - viewModel.onCallStateChanged(CallState.OUTGOING) - } - } - } - - fun onAnswerCall() { - coroutineScope.launch { - callControlScope?.let { callControlScope -> - if (callControlScope.answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL)) { - startCall() - } else { - //todo update error state - endCall() - } - } - } - } - - @RequiresApi(Build.VERSION_CODES.M) - fun onRejectCall() { - coroutineScope.launch { - callControlScope?.let { - it.disconnect(DisconnectCause(DisconnectCause.REJECTED)) - endCall() - } - } - } - - suspend fun setCallActive() : Boolean { - callControlScope?.let { - if (it.setActive()) { - startCall() - return true - } - } - return false - } - - suspend fun setCallInActive() : Boolean { - callControlScope?.let { - if (it.setInactive()) { - holdCall() - return true - } - } - return false - } - - @SuppressLint("NewApi") - fun hangUp() { - coroutineScope.launch { - callControlScope?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) - endCall() - } - } - - fun postIncomingcallNotification() { - //callNotificationSource.postIncomingCall() - } - - private fun startCall() { - fakeCallSession.startAudioLoop() - viewModel.isActive.update { true } - viewModel.onCallStateChanged(CallState.INCALL) - } - - private fun endCall() { - fakeCallSession.stopAudioLoop() - viewModel.isActive.update { false } - viewModel.onCallStateChanged(CallState.NOCALL) - } - - private fun holdCall() { - fakeCallSession.stopAudioLoop() - viewModel.isActive.update { false } - viewModel.currentCallState.update { CallState.INCALL } - } - - fun setEndpoint(callEndpoint: CallEndpointCompat) { - coroutineScope.launch { - callControlScope?.requestEndpointChange(callEndpoint) - } - } - - fun toggleMute(b: Boolean) { - viewModel.isMuted.update { !viewModel.isMuted.value } - } - - - private val callControlCallback = object : CallControlCallback { - override suspend fun onSetActive(): Boolean { - startCall() - return true - } - - override suspend fun onSetInactive(): Boolean { - holdCall() - return true - } - - override suspend fun onAnswer(callType: Int): Boolean { - startCall() - return true - } - - override suspend fun onDisconnect(disconnectCause: DisconnectCause): Boolean { - endCall() - return true - } - } -} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt deleted file mode 100644 index 4384a3ee..00000000 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomSample.kt +++ /dev/null @@ -1,235 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package com.example.platform.connectivity.telecom - -import android.Manifest - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import com.example.platform.connectivity.callnotification.NotificationSource -import com.example.platform.connectivity.telecom.screen.CallStatusWidget -import com.example.platform.connectivity.telecom.screen.DialerScreen -import com.example.platform.connectivity.telecom.screen.IncallScreen -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.MultiplePermissionsState -import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.google.android.catalog.framework.annotations.Sample -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@OptIn(ExperimentalPermissionsApi::class) -@Sample( - name = "TelecomSample", - description = "Example application showing incoming and outgoing calls using the telecom jetpack library", -) -class TelecomSample : ComponentActivity() { - - companion object { - lateinit var callViewModel: VoipViewModel - } - - lateinit var notificationSource: NotificationSource - - @OptIn(ExperimentalPermissionsApi::class) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - callViewModel = VoipViewModel(this) - notificationSource = NotificationSource(this, NotificationReceiver::class.java) - - setContent { - MaterialTheme { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - // We should be using make_own_call permissions but this requires - // implementation of the telecom API to work correctly. - // Please see telecom example for full implementation - val multiplePermissionsState = - rememberMultiplePermissionsState( - listOf( - Manifest.permission.RECORD_AUDIO, - Manifest.permission.MANAGE_OWN_CALLS, - ), - ) - - if (multiplePermissionsState.allPermissionsGranted) { - callViewModel = VoipViewModel(this) - EntryPoint(callViewModel) - } else { - PermissionWidget(multiplePermissionsState) - } - } - } - } - - CoroutineScope(Dispatchers.Main).launch { - callViewModel.currentCallState.collect { - when (it) { - TelecomManager.CallState.INCOMING -> notificationSource.postIncomingCall() - TelecomManager.CallState.INCALL -> notificationSource.postOnGoingCall() - else -> { - notificationSource.cancelNotification() - } - } - } - } - } - - class NotificationReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - intent?.let { - val notificationStateValue = it.getIntExtra( - NotificationSource.NOTIFICATION_ACTION, - NotificationSource.Companion.NotificationState.CANCEL.ordinal, - ) - - when (notificationStateValue) { - NotificationSource.Companion.NotificationState.ANSWER.ordinal -> callViewModel.answerCall() - NotificationSource.Companion.NotificationState.REJECT.ordinal -> callViewModel.rejectCall() - else -> callViewModel.disconnectCall() - } - } - } - - } -} - - -@Composable -fun EntryPoint(callViewModel: VoipViewModel) { - Box(modifier = Modifier.fillMaxSize()) { - CallingStatus(callViewModel) - Box(modifier = Modifier.align(Alignment.BottomCenter)) { - CallingBottomBar(callViewModel) - } - } -} - -@Composable -fun CallingStatus(callViewModel: VoipViewModel) { - - CallStatusWidget(callViewModel) -} - -@Composable -fun CallingBottomBar(callViewModel: VoipViewModel) { - - val callScreenState by callViewModel.currentCallState.collectAsState() - - when (callScreenState) { - TelecomManager.CallState.INCALL -> { - IncallScreen(callViewModel) - } - - TelecomManager.CallState.INCOMING -> { - - } - - TelecomManager.CallState.OUTGOING -> { - OutgoingCall() - } - - else -> { - DialerScreen(callViewModel) - } - } -} - - -@Composable -fun OutgoingCall() { - Text( - text = "Dialing out...", - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineMedium, - ) -} - -@OptIn(ExperimentalPermissionsApi::class) -@Composable -private fun PermissionWidget(permissionsState: MultiplePermissionsState) { - var showRationale by remember(permissionsState) { - mutableStateOf(false) - } - - if (showRationale) { - AlertDialog( - onDismissRequest = { showRationale = false }, - title = { - Text(text = "") - }, - text = { - Text(text = "") - }, - confirmButton = { - TextButton( - onClick = { - permissionsState.launchMultiplePermissionRequest() - }, - ) { - Text("Continue") - } - }, - dismissButton = { - TextButton( - onClick = { - showRationale = false - }, - ) { - Text("Dismiss") - } - }, - ) - } - - Button( - onClick = { - if (permissionsState.shouldShowRationale) { - showRationale = true - } else { - permissionsState.launchMultiplePermissionRequest() - } - }, - ) { - Text(text = "Grant Permission") - } -} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt deleted file mode 100644 index b9948a09..00000000 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/VoipViewModel.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.platform.connectivity.telecom - -import android.content.Context -import android.os.Build -import androidx.core.telecom.CallAttributesCompat -import androidx.core.telecom.CallEndpointCompat -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -class VoipViewModel(context: Context) : ViewModel() { - val isMuted = MutableStateFlow(false) - val isActive = MutableStateFlow(false) - val activeAudioRoute: MutableStateFlow = MutableStateFlow(null) - val availableAudioRoutes: MutableStateFlow> = - MutableStateFlow(emptyList()) - val currentCallState = MutableStateFlow(TelecomManager.CallState.NOCALL) - - var CallerName = "Jane Doe" - - private val telecomManager = TelecomManager(context, this) - - fun onMakeCall(callDirection: Int) { - when (callDirection) { - CallAttributesCompat.DIRECTION_INCOMING -> telecomManager.makeIncomingCall() - else -> telecomManager.makeOutGoingCall() - } - } - - fun toggleHoldCall(toggle: Boolean) { - viewModelScope.launch { - val hasError = when (toggle) { - - true -> telecomManager.setCallActive() - - false -> telecomManager.setCallInActive() - } - - } - } - - fun toggleMute(toggle: Boolean) { - telecomManager.toggleMute(toggle) - } - - fun answerCall() { - telecomManager.onAnswerCall() - } - - - fun rejectCall() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - telecomManager.onRejectCall() - } - } - - fun disconnectCall() { - telecomManager.hangUp() - } - - fun setEndpoint(callEndpointCompat: CallEndpointCompat) { - telecomManager.setEndpoint(callEndpointCompat) - } - - fun onCallStateChanged(callState: TelecomManager.CallState) { - currentCallState.update { callState } - - when (callState) { - TelecomManager.CallState.OUTGOING -> { - fakeDialingCall() - } - - TelecomManager.CallState.INCOMING -> {} - TelecomManager.CallState.INCALL -> {} - else -> { - onDialerScreen() - } - } - } - - /** - * Fake a dialing out Call - * Waits for 5 Seconds before setting the call to active - */ - private fun fakeDialingCall() { - viewModelScope.launch { - delay(5000) - telecomManager.setCallActive() - } - } - - private fun onDialerScreen() { - isActive.update { false } - currentCallState.update { TelecomManager.CallState.NOCALL } - activeAudioRoute.update { null } - availableAudioRoutes.update { emptyList() } - } - - fun onErrorMessage() { - - } -} - diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt new file mode 100644 index 00000000..f375baa9 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.model + +import android.os.ParcelUuid +import android.telecom.DisconnectCause +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallEndpointCompat +import kotlinx.coroutines.channels.Channel + +sealed class TelecomCall { + object None : TelecomCall() + data class Registered( + val id: ParcelUuid, + val callAttributes: CallAttributesCompat, + val isActive: Boolean, + val isOnHold: Boolean, + val isMuted: Boolean, + val currentCallEndpoint: CallEndpointCompat?, + val availableCallEndpoints: List, + internal val actionSource: Channel, + ) : TelecomCall() { + + fun isIncoming() = callAttributes.direction == CallAttributesCompat.DIRECTION_INCOMING + + /** + * Sends an action to the call session. It will be processed if it's still registered. + */ + fun processAction(action: TelecomCallAction) { + actionSource.trySend(action) + } + } + + data class Unregistered( + val id: ParcelUuid, + val callAttributes: CallAttributesCompat, + val disconnectCause: DisconnectCause, + ) : TelecomCall() +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt new file mode 100644 index 00000000..b9b4ca2f --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.model + +import android.os.ParcelUuid +import android.os.Parcelable +import android.telecom.DisconnectCause +import kotlinx.parcelize.Parcelize + +sealed interface TelecomCallAction : Parcelable { + @Parcelize + object Answer : TelecomCallAction + + @Parcelize + data class Disconnect(val cause: DisconnectCause) : TelecomCallAction + + @Parcelize + object Hold : TelecomCallAction + + @Parcelize + object Activate : TelecomCallAction + + @Parcelize + data class Mute(val isMute: Boolean) : TelecomCallAction + + @Parcelize + data class SwitchAudioType(val type: Int) : TelecomCallAction + + @Parcelize + data class TransferCall(val id: ParcelUuid) : TelecomCallAction +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt new file mode 100644 index 00000000..4d922635 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -0,0 +1,335 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.model + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.telecom.DisconnectCause +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallControlCallback +import androidx.core.telecom.CallControlScope +import androidx.core.telecom.CallEndpointCompat +import androidx.core.telecom.CallsManager +import com.example.platform.connectivity.audio.datasource.AudioLoopSource +import com.example.platform.connectivity.telecom.TelecomCallNotificationManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@RequiresApi(Build.VERSION_CODES.O) +class TelecomCallRepository( + private val applicationScope: CoroutineScope, + private val callsManager: CallsManager, + private val audioLoopSource: AudioLoopSource, + private val notificationManager: TelecomCallNotificationManager, +) { + + companion object { + var instance: TelecomCallRepository? = null + private set + + /** + * This does not illustrate best practices for instantiating classes in Android but for + * simplicity we use this create method to create a singleton with the CallsManager class. + */ + fun create(context: Context): TelecomCallRepository { + Log.d("MPB", "New instance") + check(instance == null) { + "CallRepository instance already created" + } + + // Create the Jetpack Telecom entry point + val callsManager = CallsManager(context).apply { + // Register with the telecom interface with the supported capabilities + registerAppWithTelecom( + capabilities = CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING and + CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING, + ) + } + + return TelecomCallRepository( + applicationScope = CoroutineScope(SupervisorJob()), + callsManager = callsManager, + audioLoopSource = AudioLoopSource(), + notificationManager = TelecomCallNotificationManager(context), + ).also { + instance = it + } + } + } + + // Keeps track of the current TelecomCall state + private val _currentCall: MutableStateFlow = MutableStateFlow(TelecomCall.None) + val currentCall = _currentCall.asStateFlow() + + /** + * Register a new call with the provided attributes. + * Use the [currentCall] StateFlow to receive status updates and process call related actions. + */ + fun registerCall(displayName: String, address: Uri, isIncoming: Boolean) { + // For simplicity we don't support multiple calls + check(_currentCall.value !is TelecomCall.Registered) { + "There cannot be more than one call at the same time." + } + + // Create the call attributes + val attributes = CallAttributesCompat( + displayName = displayName, + address = address, + direction = if (isIncoming) { + CallAttributesCompat.DIRECTION_INCOMING + } else { + CallAttributesCompat.DIRECTION_OUTGOING + }, + callType = CallAttributesCompat.CALL_TYPE_AUDIO_CALL, + callCapabilities = (CallAttributesCompat.SUPPORTS_SET_INACTIVE + or CallAttributesCompat.SUPPORTS_STREAM + or CallAttributesCompat.SUPPORTS_TRANSFER), + ) + + // Creates a channel to send actions to the call scope. + val actionSource = Channel() + + // We launch the call in our application scope so it keeps registered while the app is alive + // or until the user explicitly disconnects it. + applicationScope.launch { + if (isIncoming) { + // Fake incoming call delay + delay(2000) + } + // Register the call and handle actions in the scope + callsManager.addCall(attributes) { + // Register the callback to be notified about other call actions + // from other services or devices + // TODO this should eventually be moved inside the addCall method b/290562928 + setCallback( + object : CallControlCallback { + override suspend fun onAnswer(callType: Int): Boolean { + TODO("Not yet implemented") + } + + override suspend fun onDisconnect(disconnectCause: DisconnectCause): Boolean { + TODO("Not yet implemented") + } + + override suspend fun onSetActive(): Boolean { + TODO("Not yet implemented") + } + + override suspend fun onSetInactive(): Boolean { + TODO("Not yet implemented") + } + }, + ) + + launch { + processCallStatus() + } + + launch { + processCallActions(actionSource.consumeAsFlow()) + } + + // TODO Use the state value once b/290538853 is fixed + _currentCall.value = TelecomCall.Registered( + id = getCallId(), + isActive = false, + isOnHold = false, + callAttributes = attributes, + isMuted = false, + currentCallEndpoint = null, + availableCallEndpoints = emptyList(), + actionSource = actionSource, + ) + + launch { + currentCallEndpoint.collect { + updateCurrentCall { + copy(currentCallEndpoint = it) + } + } + } + launch { + availableEndpoints.collect { + updateCurrentCall { + copy(availableCallEndpoints = it) + } + } + } + launch { + isMuted.collect { + updateCurrentCall { + copy(isMuted = it) + } + } + } + } + } + } + + /** + * Collect the action source to handle client actions inside the call scope + */ + private suspend fun CallControlScope.processCallActions(actionSource: Flow) { + try { + actionSource.collect { action -> + when (action) { + is TelecomCallAction.Answer -> doAnswer() + + is TelecomCallAction.Disconnect -> { + doDisconnect(action) + } + + is TelecomCallAction.SwitchAudioType -> doSwitchEndpoint(action) + + is TelecomCallAction.TransferCall -> { + val call = _currentCall.value as? TelecomCall.Registered + val endpoints = call?.availableCallEndpoints?.firstOrNull { + it.identifier == action.id + } + requestEndpointChange( + endpoint = endpoints ?: return@collect, + ) + } + + TelecomCallAction.Hold -> if (setInactive()) { + updateCurrentCall { + copy(isOnHold = true) + } + } + + TelecomCallAction.Activate -> if (setActive()) { + updateCurrentCall { + copy( + isActive = true, + isOnHold = false, + ) + } + } + + is TelecomCallAction.Mute -> { + // We cannot programmatically mute the telecom stack. Instead we just update + // the state of the call and this will start/stop audio capturing. + updateCurrentCall { + copy(isMuted = !isMuted) + } + } + } + } + } finally { + // TODO this finally block should be when calling addCall once it implements + // the CoroutineContext + Log.d("MPB", "Exit scope") + _currentCall.update { + TelecomCall.None + } + } + } + + /** + * Collects changes in the call status to coordinate call related actors like showing the + * notification for the call, start/stop audio... + */ + private suspend fun processCallStatus() { + _currentCall.collect { call -> + Log.d("MPB", "Call status changed: $call") + notificationManager.updateCallNotification(call) + + when (call) { + TelecomCall.None, is TelecomCall.Unregistered -> { + audioLoopSource.stopAudioLoop() + } + + is TelecomCall.Registered -> { + if (call.isActive && !call.isOnHold && !call.isMuted) { + audioLoopSource.startAudioLoop() + } else { + audioLoopSource.stopAudioLoop() + } + } + } + } + } + + /** + * Update the current state of our call applying the transform lambda only if the call is + * registered. Otherwise keep the current state + */ + private fun updateCurrentCall(transform: TelecomCall.Registered.() -> TelecomCall) { + _currentCall.update { call -> + if (call is TelecomCall.Registered) { + call.transform() + } else { + call + } + } + } + + private suspend fun CallControlScope.doSwitchEndpoint(action: TelecomCallAction.SwitchAudioType) { + // TODO once availableCallEndpoints is a state flow we can just get the value + val endpoints = (_currentCall.value as TelecomCall.Registered).availableCallEndpoints + + // Switch to the given endpoint or fallback to the best possible one. + val newEndpoint = endpoints.firstOrNull { it.type == action.type } + ?: endpoints.firstOrNull { + it.type == CallEndpointCompat.TYPE_BLUETOOTH + } ?: endpoints.firstOrNull { + it.type == CallEndpointCompat.TYPE_WIRED_HEADSET + } ?: endpoints.firstOrNull { + it.type == CallEndpointCompat.TYPE_EARPIECE + } ?: endpoints.firstOrNull() + + if (newEndpoint != null) { + requestEndpointChange(newEndpoint).also { + Log.d("MPB", "Endpoint ${newEndpoint.name} changed: $it") + } + } + } + + private suspend fun CallControlScope.doDisconnect(action: TelecomCallAction.Disconnect) { + disconnect(action.cause) + updateCurrentCall { + TelecomCall.Unregistered(id, callAttributes, action.cause) + } + } + + private suspend fun CallControlScope.doAnswer() { + if (answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL)) { + updateCurrentCall { + copy(isActive = true, isOnHold = false) + } + } else { + updateCurrentCall { + TelecomCall.Unregistered( + id = id, + callAttributes = callAttributes, + disconnectCause = DisconnectCause(DisconnectCause.BUSY), + ) + } + } + } +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt deleted file mode 100644 index 74fddaaa..00000000 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/CallStatusUI.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.platform.connectivity.telecom.screen - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.example.platform.connectivity.telecom.TelecomManager -import com.example.platform.connectivity.telecom.VoipViewModel - -@Composable -fun CallStatusWidget(callViewModel: VoipViewModel) { - - val callState by callViewModel.currentCallState.collectAsState() - val callStatus = when (callState) { - TelecomManager.CallState.INCALL -> "In Call" - TelecomManager.CallState.INCOMING -> "Incoming Call" - TelecomManager.CallState.OUTGOING -> "Outgoing Call" - else -> "No Call" - } - - val activeDeviceName by callViewModel.activeAudioRoute.collectAsState() - val isMuted by callViewModel.isMuted.collectAsState() - val isActive by callViewModel.isActive.collectAsState() - val activeDevices by callViewModel.availableAudioRoutes.collectAsState() - - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) { - Column(Modifier.padding(30.dp)) { - Text( - text = String.format("Call Status: %s", callStatus), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineMedium, - ) - Text( - text = String.format("Audio Device: %s", activeDeviceName?.name), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineMedium, - ) - Text( - text = String.format("Mute State: %b", isMuted), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineMedium, - ) - Text( - text = String.format("Active State: %b", isActive), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineMedium, - ) - Text( - text = String.format("Caller: %s", callViewModel.CallerName), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineMedium, - ) - - activeDevices.forEach { - Text( - text = String.format("Caller: %s", it.name), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineMedium, - ) - } - } - } -} - diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt deleted file mode 100644 index e975d309..00000000 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/DialerScreen.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.platform.connectivity.telecom.screen - -import android.R -import androidx.compose.material3.Icon -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.painterResource -import androidx.core.telecom.CallAttributesCompat -import com.example.platform.connectivity.telecom.VoipViewModel - -@Composable -fun DialerScreen(callViewModel: VoipViewModel) { - DialerBottomBar( - { callViewModel.onMakeCall(CallAttributesCompat.DIRECTION_OUTGOING) }, - { callViewModel.onMakeCall(CallAttributesCompat.DIRECTION_INCOMING) }, - ) -} - -@Composable -fun DialerBottomBar( - onOutgoingCall: () -> Unit, - onIncomingCall: () -> Unit, -) { - NavigationBar { - NavigationBarItem( - icon = { - Icon( - painter = painterResource(id = R.drawable.sym_call_outgoing), - contentDescription = "Outgoing Call", - ) - }, - label = { Text("Outgoing Call") }, - selected = false, - onClick = onOutgoingCall, - ) - NavigationBarItem( - icon = { - Icon( - painter = painterResource(id = R.drawable.sym_call_incoming), - contentDescription = "Incoming Call", - ) - }, - label = { Text("Incoming Call") }, - selected = false, - onClick = onIncomingCall, - ) - } -} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt deleted file mode 100644 index 8a6a1776..00000000 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/screen/IncallScreen.kt +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.platform.connectivity.telecom.screen - -import android.R -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Box -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Phone -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledIconToggleButton -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.core.telecom.CallEndpointCompat -import com.example.platform.connectivity.telecom.R.drawable -import com.example.platform.connectivity.telecom.VoipViewModel - -@Composable -fun IncallScreen(callViewModel: VoipViewModel) { - val callEndPoints by callViewModel.availableAudioRoutes.collectAsState() - val muteState by callViewModel.isMuted.collectAsState() - val activeState by callViewModel.isActive.collectAsState() - - IncallBottomBar( - callEndPoints, - muteState, - callViewModel::toggleMute, - callViewModel::toggleHoldCall, - { callViewModel.disconnectCall()}, - activeState, - callViewModel::setEndpoint - ) -} - -@Composable -fun IncallBottomBar( - endPoints: List, - muteState: Boolean, - onMuteChanged: (Boolean) -> Unit, - onHoldCall: (Boolean) -> Unit, - onHangUp: () -> Unit, - activeState: Boolean, - onAudioDeviceSelected: (CallEndpointCompat) -> Unit, -) { - - var audioDeviceWidgetState by remember { mutableStateOf(false) } - - BottomAppBar( - actions = { - ToggleButton( - drawable.mic_off, - drawable.mic_on, - muteState, - onMuteChanged, - ) - Box { - IconButton(onClick = { audioDeviceWidgetState = !audioDeviceWidgetState }) { - Icon( - painter = painterResource(id = drawable.speaker), - contentDescription = "Localized description", - ) - } - DropdownMenu( - expanded = audioDeviceWidgetState, - onDismissRequest = { audioDeviceWidgetState = false }, - ) { - endPoints.forEach{ - CallEndPointItem( - endPointUI = it, - onDeviceSelected = onAudioDeviceSelected, - ) - } - } - } - ToggleButton( - R.drawable.ic_menu_call, - R.drawable.stat_sys_phone_call_on_hold, - activeState, - onHoldCall, - ) - }, - floatingActionButton = { - FloatingActionButton( - onClick = onHangUp, - containerColor = Color.Red, - elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), - ) { - Icon( - painter = painterResource(id = drawable.call_end), - "Localized description", - ) - } - }, - ) -} - - -@Composable -fun ToggleButton( - positiveResID: Int, - negativeResID: Int, - toggleState: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - FilledIconToggleButton(checked = toggleState, onCheckedChange = onCheckedChange) { - if (toggleState) { - Icon(painter = painterResource(id = positiveResID), "Switch On") - } else { - Icon(painter = painterResource(id = negativeResID), "Switch Off") - } - } -} - -/** - * Displays the audio device with Icon and Text - */ -@SuppressLint("NewApi") -@Composable -private fun CallEndPointItem( - endPointUI: CallEndpointCompat, - onDeviceSelected: (CallEndpointCompat) -> Unit, -) { - DropdownMenuItem( - text = { Text(endPointUI.name.toString()) }, - onClick = { onDeviceSelected(endPointUI) }, - leadingIcon = { - - Icon( - Icons.Outlined.Phone, - contentDescription = null, - ) - } - ) -} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/res/drawable/call_end.xml b/samples/connectivity/telecom/src/main/res/drawable/call_end.xml deleted file mode 100644 index a3d3bc0f..00000000 --- a/samples/connectivity/telecom/src/main/res/drawable/call_end.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/samples/connectivity/telecom/src/main/res/drawable/ic_phone_paused_24.xml b/samples/connectivity/telecom/src/main/res/drawable/ic_phone_paused_24.xml new file mode 100644 index 00000000..a9afe06f --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/ic_phone_paused_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/samples/connectivity/telecom/src/main/res/drawable/ic_round_call_24.xml b/samples/connectivity/telecom/src/main/res/drawable/ic_round_call_24.xml new file mode 100644 index 00000000..c22fb83a --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/ic_round_call_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/samples/connectivity/telecom/src/main/res/drawable/mic_off.xml b/samples/connectivity/telecom/src/main/res/drawable/mic_off.xml deleted file mode 100644 index 31632df3..00000000 --- a/samples/connectivity/telecom/src/main/res/drawable/mic_off.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/samples/connectivity/telecom/src/main/res/drawable/mic_on.xml b/samples/connectivity/telecom/src/main/res/drawable/mic_on.xml deleted file mode 100644 index 1328b565..00000000 --- a/samples/connectivity/telecom/src/main/res/drawable/mic_on.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/samples/connectivity/telecom/src/main/res/drawable/speaker.xml b/samples/connectivity/telecom/src/main/res/drawable/speaker.xml deleted file mode 100644 index 6112ada9..00000000 --- a/samples/connectivity/telecom/src/main/res/drawable/speaker.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/samples/user-interface/haptics/build.gradle.kts b/samples/user-interface/haptics/build.gradle.kts index b2bda624..07600256 100644 --- a/samples/user-interface/haptics/build.gradle.kts +++ b/samples/user-interface/haptics/build.gradle.kts @@ -21,7 +21,3 @@ plugins { android { namespace = "com.example.platform.ui.haptics" } - -dependencies { - implementation(libs.compose.material.iconsext) -} diff --git a/samples/user-interface/text/src/main/AndroidManifest.xml b/samples/user-interface/text/src/main/AndroidManifest.xml index a0eb8b57..e95894d3 100644 --- a/samples/user-interface/text/src/main/AndroidManifest.xml +++ b/samples/user-interface/text/src/main/AndroidManifest.xml @@ -13,8 +13,7 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + diff --git a/settings.gradle.kts b/settings.gradle.kts index fe1bc399..41990e65 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,6 +28,9 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + + // Using SNAPSHOTS for Telecom SDK. This should be removed once telecom SDK is stable + maven { url = uri("https://androidx.dev/snapshots/builds/10465142/artifacts/repository") } } } From 798843f27b1788803279db0cff7f392bd378f10a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Fri, 14 Jul 2023 10:46:47 +0200 Subject: [PATCH 13/41] Clean up code and documentation Change-Id: I912fd0512be5d4897f3733276d7780bf20bf9aa8 --- .../telecom/src/main/AndroidManifest.xml | 5 +- .../telecom/TelecomCallBroadcast.kt | 5 +- .../telecom/TelecomCallNotificationManager.kt | 33 +- .../telecom/TelecomCallSampleActivity.kt | 483 +---------------- .../connectivity/telecom/TelecomCallScreen.kt | 496 ++++++++++++++++++ .../connectivity/telecom/model/TelecomCall.kt | 24 +- .../telecom/model/TelecomCallAction.kt | 11 +- .../telecom/model/TelecomCallRepository.kt | 11 +- 8 files changed, 574 insertions(+), 494 deletions(-) create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt diff --git a/samples/connectivity/telecom/src/main/AndroidManifest.xml b/samples/connectivity/telecom/src/main/AndroidManifest.xml index d2b15f05..0b65a4a3 100644 --- a/samples/connectivity/telecom/src/main/AndroidManifest.xml +++ b/samples/connectivity/telecom/src/main/AndroidManifest.xml @@ -16,9 +16,9 @@ - - + + @@ -31,7 +31,6 @@ android:showOnLockScreen="true"> - diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt index 75a72aad..5c0d2ae8 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt @@ -20,7 +20,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build -import android.util.Log import androidx.annotation.RequiresApi import com.example.platform.connectivity.telecom.model.TelecomCall import com.example.platform.connectivity.telecom.model.TelecomCallAction @@ -33,7 +32,6 @@ import com.example.platform.connectivity.telecom.model.TelecomCallRepository class TelecomCallBroadcast : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - Log.d("MPB", "onReceive: ${intent.extras}") // Get the action or skip if none val action = intent.getTelecomCallAction() ?: return val repo = TelecomCallRepository.instance ?: TelecomCallRepository.create(context) @@ -45,6 +43,9 @@ class TelecomCallBroadcast : BroadcastReceiver() { } } + /** + * Get the [TelecomCallAction] parcelable object from the intent bundle. + */ private fun Intent.getTelecomCallAction() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { getParcelableExtra( diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt index 615ce68e..646ea6d0 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt @@ -17,6 +17,7 @@ package com.example.platform.connectivity.telecom import android.Manifest +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -24,6 +25,7 @@ import android.media.RingtoneManager import android.os.Build import android.telecom.DisconnectCause import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -32,6 +34,12 @@ import androidx.core.content.PermissionChecker import com.example.platform.connectivity.telecom.model.TelecomCall import com.example.platform.connectivity.telecom.model.TelecomCallAction +/** + * Handles call status changes and updates the notification accordingly. For more guidance around + * notifications check https://developer.android.com/develop/ui/views/notifications + * + * @see updateCallNotification + */ @RequiresApi(Build.VERSION_CODES.O) class TelecomCallNotificationManager(private val context: Context) { internal companion object { @@ -45,6 +53,9 @@ class TelecomCallNotificationManager(private val context: Context) { private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(context) + /** + * Updates, creates or dismisses a CallStyle notification based on the given [TelecomCall] + */ fun updateCallNotification(call: TelecomCall) { // If notifications are not granted, skip it. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && @@ -59,24 +70,30 @@ class TelecomCallNotificationManager(private val context: Context) { // Ensure that the channel is created createNotificationChannel() - // Update notification + // Update or dismiss notification when (call) { TelecomCall.None, is TelecomCall.Unregistered -> cancelNotification() is TelecomCall.Registered -> updateNotification(call) } } + @SuppressLint("InlinedApi") + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) private fun updateNotification(call: TelecomCall.Registered) { + // To display the caller information val caller = Person.Builder() .setName(call.callAttributes.displayName) .setUri(call.callAttributes.address.toString()) .setImportant(true) .build() + + // Defines the full screen notification activity or the activity to launch once the user taps + // on the notification val contentIntent = PendingIntent.getActivity( - context, - 0, - Intent(context, TelecomCallSampleActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + /* context = */ context, + /* requestCode = */ 0, + /* intent = */ Intent(context, TelecomCallSampleActivity::class.java), + /* flags = */ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) // Define the call style based on the call state and set the right actions @@ -102,7 +119,6 @@ class TelecomCallNotificationManager(private val context: Context) { } val builder = NotificationCompat.Builder(context, TELECOM_NOTIFICATION_CHANNEL_ID) - .setContentText("test") .setContentIntent(contentIntent) .setFullScreenIntent(contentIntent, true) .setSmallIcon(R.drawable.ic_round_call_24) @@ -123,7 +139,6 @@ class TelecomCallNotificationManager(private val context: Context) { ) } - @Suppress("MissingPermission") notificationManager.notify(TELECOM_NOTIFICATION_ID, builder.build()) } @@ -131,6 +146,10 @@ class TelecomCallNotificationManager(private val context: Context) { notificationManager.cancel(TELECOM_NOTIFICATION_ID) } + /** + * Creates a PendingIntent for the given [TelecomCallAction]. Since the actions are parcelable + * we can directly pass them as extra parameters in the bundle. + */ private fun getPendingIntent(action: TelecomCallAction): PendingIntent { val callIntent = Intent(context, TelecomCallBroadcast::class.java) callIntent.putExtra( diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt index 2dab5701..9c606cd2 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt @@ -18,88 +18,39 @@ package com.example.platform.connectivity.telecom import android.Manifest import android.app.KeyguardManager -import android.net.Uri import android.os.Build import android.os.Bundle -import android.telecom.DisconnectCause -import android.util.Log import android.view.WindowManager -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.annotation.RequiresApi -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.BluetoothAudio -import androidx.compose.material.icons.rounded.BluetoothDisabled -import androidx.compose.material.icons.rounded.Call -import androidx.compose.material.icons.rounded.Mic -import androidx.compose.material.icons.rounded.MicOff -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.PhoneForwarded -import androidx.compose.material.icons.rounded.PhonePaused -import androidx.compose.material.icons.rounded.VolumeUp -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import androidx.core.content.getSystemService -import androidx.core.telecom.CallEndpointCompat import com.example.platform.base.PermissionBox -import com.example.platform.connectivity.telecom.model.TelecomCall -import com.example.platform.connectivity.telecom.model.TelecomCallAction import com.example.platform.connectivity.telecom.model.TelecomCallRepository import com.google.android.catalog.framework.annotations.Sample -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch @Sample( name = "Telecom Call Sample", description = "A sample showcasing how to handle calls with the Jetpack Telecom API", + documentation = "https://developer.android.com/guide/topics/connectivity/telecom" ) @RequiresApi(Build.VERSION_CODES.O) class TelecomCallSampleActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Log.d("MPB", "onCreate:") setupCallActivity() + + // The repo contains all the call logic and communication with the Telecom SDK. + val repository = + TelecomCallRepository.instance ?: TelecomCallRepository.create(applicationContext) + setContent { MaterialTheme { Surface( @@ -118,12 +69,9 @@ class TelecomCallSampleActivity : ComponentActivity() { } else { listOf(Manifest.permission.RECORD_AUDIO) } - val context = LocalContext.current - val repository = remember { - TelecomCallRepository.instance ?: TelecomCallRepository.create(context) - } + PermissionBox(permissions = permissions) { - CallerScreen(repository) + TelecomCallScreen(repository) } } } @@ -151,418 +99,3 @@ class TelecomCallSampleActivity : ComponentActivity() { } } } - -@RequiresApi(Build.VERSION_CODES.O) -@Composable -private fun CallerScreen(repository: TelecomCallRepository) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - - // Collect the current call state and update UI - val call by repository.currentCall.collectAsState() - - // If call goes unregistered inform user - if (call is TelecomCall.Unregistered) { - LaunchedEffect(true) { - Toast.makeText(context, "Call disconnected", Toast.LENGTH_SHORT).show() - } - } - - when (val newCall = call) { - is TelecomCall.Unregistered, TelecomCall.None -> { - // Show calling menu when there is no active call - NoCallScreen( - incomingCall = { - Toast.makeText(context, "Incoming call in 2 seconds", Toast.LENGTH_SHORT).show() - scope.launch(Dispatchers.IO) { - repository.registerCall( - displayName = "Alice", - address = Uri.parse(""), - isIncoming = true, - ) - } - }, - outgoingCall = { - scope.launch(Dispatchers.IO) { - repository.registerCall( - displayName = "Bob", - address = Uri.parse(""), - isIncoming = false, - ) - - // Faking that the other end is not picking it - delay(2000) - - // The other end answered, activate the call - (repository.currentCall.value as? TelecomCall.Registered)?.processAction( - TelecomCallAction.Activate, - ) - } - }, - ) - } - - is TelecomCall.Registered -> { - // Call screen only contains the logic to represent the values of the active call - // and process user input by calling the processAction of the active call. - CallScreen( - name = newCall.callAttributes.displayName.toString(), - info = newCall.callAttributes.address.toString(), - incoming = newCall.isIncoming(), - isActive = newCall.isActive, - isOnHold = newCall.isOnHold, - isMuted = newCall.isMuted, - currentEndpoint = newCall.currentCallEndpoint, - endpoints = newCall.availableCallEndpoints, - onCallAction = newCall::processAction, - ) - } - } -} - -@Composable -private fun NoCallScreen(incomingCall: () -> Unit, outgoingCall: () -> Unit) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = "No active call", style = MaterialTheme.typography.titleLarge) - Button(onClick = incomingCall) { - Text(text = "Receive fake call") - } - Button(onClick = outgoingCall) { - Text(text = "Make fake call") - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@RequiresApi(Build.VERSION_CODES.O) -@Composable -private fun CallScreen( - name: String, - info: String, - incoming: Boolean, - isActive: Boolean, - isOnHold: Boolean, - isMuted: Boolean, - currentEndpoint: CallEndpointCompat?, - endpoints: List, - onCallAction: (TelecomCallAction) -> Unit, -) { - var showTransferEndpoints by remember { - mutableStateOf(false) - } - val transferEndpoints = remember(endpoints) { - endpoints.filter { it.type == CallEndpointCompat.TYPE_STREAMING } - } - Column( - Modifier - .fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - CallInfoCard(name, info, isActive) - if (incoming && !isActive) { - IncomingCallActions(onCallAction) - } else { - OngoingCallActions( - isActive, - isOnHold, - isMuted, - currentEndpoint, - endpoints, - onCallAction, - showTransferEndpoints, - ) - } - } - - // Show a picker when selecting to transfer a call - AnimatedVisibility(visible = showTransferEndpoints) { - TransferCallDialog( - showTransferEndpoints = showTransferEndpoints, - transferEndpoints = transferEndpoints, - onDismissRequest = { - showTransferEndpoints = false - }, - onCallAction = { - showTransferEndpoints = false - onCallAction(it) - }, - ) - } -} - -@RequiresApi(Build.VERSION_CODES.O) -@Composable -private fun OngoingCallActions( - isActive: Boolean, - isOnHold: Boolean, - isMuted: Boolean, - currentEndpoint: CallEndpointCompat?, - endpoints: List, - onCallAction: (TelecomCallAction) -> Unit, - showTransferEndpoints: Boolean, -) { - var showTransferEndpoints1 = showTransferEndpoints - Column( - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primaryContainer) - .shadow(1.dp) - .padding(26.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - CallControls( - isActive = isActive, - isOnHold = isOnHold, - isMuted = isMuted, - endpointType = currentEndpoint?.type ?: CallEndpointCompat.TYPE_UNKNOWN, - availableTypes = endpoints.map { it.type }, - onCallAction = onCallAction, - onTransferCall = { showTransferEndpoints1 = true }, - ) - FloatingActionButton( - onClick = { - onCallAction( - TelecomCallAction.Disconnect( - DisconnectCause( - DisconnectCause.LOCAL, - ), - ), - ) - }, - containerColor = MaterialTheme.colorScheme.error, - ) { - Icon( - imageVector = Icons.Rounded.Call, - contentDescription = null, - modifier = Modifier.rotate(90f), - ) - } - } -} - -@RequiresApi(Build.VERSION_CODES.M) -@Composable -private fun IncomingCallActions(onCallAction: (TelecomCallAction) -> Unit) { - Row( - Modifier - .fillMaxWidth() - .padding(26.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - FloatingActionButton( - onClick = { - onCallAction( - TelecomCallAction.Disconnect( - DisconnectCause( - DisconnectCause.REJECTED, - ), - ), - ) - }, - containerColor = MaterialTheme.colorScheme.error, - ) { - Icon( - imageVector = Icons.Rounded.Call, - contentDescription = null, - modifier = Modifier.rotate(90f), - ) - } - FloatingActionButton( - onClick = { - onCallAction( - TelecomCallAction.Answer, - ) - }, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon(imageVector = Icons.Rounded.Call, contentDescription = null) - } - } -} - -@Composable -private fun CallInfoCard(name: String, info: String, isActive: Boolean) { - Column( - Modifier - .fillMaxSize(0.5f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image(imageVector = Icons.Rounded.Person, contentDescription = null) - Text(text = name, style = MaterialTheme.typography.titleMedium) - Text(text = info, style = MaterialTheme.typography.bodyMedium) - - if (!isActive) { - Text(text = "Connecting...", style = MaterialTheme.typography.titleSmall) - } else { - Text(text = "Connected", style = MaterialTheme.typography.titleSmall) - } - - } -} - -@Composable -fun CallControls( - isActive: Boolean, - isOnHold: Boolean, - isMuted: Boolean, - endpointType: @CallEndpointCompat.Companion.EndpointType Int, - availableTypes: List<@CallEndpointCompat.Companion.EndpointType Int>, - onCallAction: (TelecomCallAction) -> Unit, - onTransferCall: () -> Unit, -) { - val isLocalCall = endpointType != CallEndpointCompat.TYPE_STREAMING - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - IconToggleButton( - checked = isMuted, - onCheckedChange = { - onCallAction(TelecomCallAction.Mute(it)) - }, - ) { - if (isMuted) { - Icon(imageVector = Icons.Rounded.MicOff, contentDescription = "Mic on") - } else { - Icon(imageVector = Icons.Rounded.Mic, contentDescription = "Mic off") - } - } - IconToggleButton( - checked = endpointType == CallEndpointCompat.TYPE_SPEAKER, - enabled = isLocalCall, - onCheckedChange = { selected -> - val type = if (selected) { - CallEndpointCompat.TYPE_SPEAKER - } else { - // Switch to either wired headset or earpiece - availableTypes.firstOrNull { it == CallEndpointCompat.TYPE_WIRED_HEADSET } - ?: CallEndpointCompat.TYPE_EARPIECE - } - onCallAction(TelecomCallAction.SwitchAudioType(type)) - }, - ) { - Icon(imageVector = Icons.Rounded.VolumeUp, contentDescription = "Toggle speaker") - } - if (availableTypes.contains(CallEndpointCompat.TYPE_BLUETOOTH)) { - IconToggleButton( - checked = endpointType == CallEndpointCompat.TYPE_BLUETOOTH, - enabled = isLocalCall, - onCheckedChange = { selected -> - val type = if (selected) { - CallEndpointCompat.TYPE_BLUETOOTH - } else { - // Switch to the default endpoint (as defined in TelecomCallRepo) - availableTypes.firstOrNull { it == CallEndpointCompat.TYPE_WIRED_HEADSET } - ?: CallEndpointCompat.TYPE_EARPIECE - } - onCallAction(TelecomCallAction.SwitchAudioType(type)) - }, - ) { - if (endpointType == CallEndpointCompat.TYPE_BLUETOOTH) { - Icon( - imageVector = Icons.Rounded.BluetoothAudio, - contentDescription = "Disable bluetooth", - ) - } else { - Icon( - imageVector = Icons.Rounded.BluetoothDisabled, - contentDescription = "Enable bluetooth", - ) - } - } - } - IconToggleButton( - enabled = isActive, - checked = isOnHold, - onCheckedChange = { - val action = if (it) { - TelecomCallAction.Hold - } else { - TelecomCallAction.Activate - } - onCallAction(action) - }, - ) { - Icon( - imageVector = Icons.Rounded.PhonePaused, - contentDescription = "Pause or resume call", - ) - } - - if (availableTypes.contains(CallEndpointCompat.TYPE_STREAMING)) { - IconToggleButton( - enabled = isActive, - checked = !isLocalCall, - onCheckedChange = { - if (it) { - onTransferCall() - } else { - // Switch back to the default audio type - onCallAction(TelecomCallAction.SwitchAudioType(CallEndpointCompat.TYPE_UNKNOWN)) - } - }, - ) { - Icon( - imageVector = Icons.Rounded.PhoneForwarded, - contentDescription = "Transfer call", - ) - } - } - } -} - - -@RequiresApi(Build.VERSION_CODES.O) -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun TransferCallDialog( - showTransferEndpoints: Boolean, - transferEndpoints: List, - onDismissRequest: () -> Unit, - onCallAction: (TelecomCallAction) -> Unit, -) { - var showTransferEndpoints1 = showTransferEndpoints - AlertDialog( - onDismissRequest = onDismissRequest, - ) { - Surface( - modifier = Modifier - .wrapContentWidth() - .wrapContentHeight(), - shape = MaterialTheme.shapes.large, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - Column(Modifier.padding(16.dp)) { - Text(text = "Where to transfer the call?") - LazyColumn { - items(transferEndpoints) { - Text( - text = it.name.toString(), - modifier = Modifier - .fillMaxWidth() - .clickable { - onCallAction(TelecomCallAction.TransferCall(it.identifier)) - }, - ) - } - } - Row(horizontalArrangement = Arrangement.End) { - OutlinedButton(onClick = { showTransferEndpoints1 = false }) { - Text(text = "Dismiss") - } - } - } - } - } -} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt new file mode 100644 index 00000000..bf0c1006 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt @@ -0,0 +1,496 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import android.net.Uri +import android.os.Build +import android.telecom.DisconnectCause +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BluetoothAudio +import androidx.compose.material.icons.rounded.BluetoothDisabled +import androidx.compose.material.icons.rounded.Call +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.MicOff +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PhoneForwarded +import androidx.compose.material.icons.rounded.PhonePaused +import androidx.compose.material.icons.rounded.VolumeUp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.telecom.CallEndpointCompat +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallAction +import com.example.platform.connectivity.telecom.model.TelecomCallRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * This composable observes the current state of the call and updates the UI based on its attributes + * + * Note: this only contains UI logic. All the telecom related actions are in [TelecomCallRepository] + */ +@RequiresApi(Build.VERSION_CODES.O) +@Composable +internal fun TelecomCallScreen(repository: TelecomCallRepository) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Collect the current call state and update UI + val call by repository.currentCall.collectAsState() + + // If call goes unregistered inform user + if (call is TelecomCall.Unregistered) { + LaunchedEffect(Unit) { + Toast.makeText(context, "Call disconnected", Toast.LENGTH_SHORT).show() + } + } + + when (val newCall = call) { + is TelecomCall.Unregistered, TelecomCall.None -> { + // Show calling menu when there is no active call + NoCallScreen( + incomingCall = { + Toast.makeText(context, "Incoming call in 2 seconds", Toast.LENGTH_SHORT).show() + scope.launch(Dispatchers.IO) { + repository.registerCall( + displayName = "Alice", + address = Uri.parse(""), + isIncoming = true, + ) + } + }, + outgoingCall = { + scope.launch(Dispatchers.IO) { + repository.registerCall( + displayName = "Bob", + address = Uri.parse(""), + isIncoming = false, + ) + + // Faking that the other end is not picking it + delay(2000) + + // The other end answered, activate the call + (repository.currentCall.value as? TelecomCall.Registered)?.processAction( + TelecomCallAction.Activate, + ) + } + }, + ) + } + + is TelecomCall.Registered -> { + // Call screen only contains the logic to represent the values of the active call + // and process user input by calling the processAction of the active call. + CallScreen( + name = newCall.callAttributes.displayName.toString(), + info = newCall.callAttributes.address.toString(), + incoming = newCall.isIncoming(), + isActive = newCall.isActive, + isOnHold = newCall.isOnHold, + isMuted = newCall.isMuted, + currentEndpoint = newCall.currentCallEndpoint, + endpoints = newCall.availableCallEndpoints, + onCallAction = newCall::processAction, + ) + } + } +} + +@Composable +private fun NoCallScreen(incomingCall: () -> Unit, outgoingCall: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "No active call", style = MaterialTheme.typography.titleLarge) + Button(onClick = incomingCall) { + Text(text = "Receive fake call") + } + Button(onClick = outgoingCall) { + Text(text = "Make fake call") + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun CallScreen( + name: String, + info: String, + incoming: Boolean, + isActive: Boolean, + isOnHold: Boolean, + isMuted: Boolean, + currentEndpoint: CallEndpointCompat?, + endpoints: List, + onCallAction: (TelecomCallAction) -> Unit, +) { + var showTransferEndpoints by remember { + mutableStateOf(false) + } + val transferEndpoints = remember(endpoints) { + endpoints.filter { it.type == CallEndpointCompat.TYPE_STREAMING } + } + Column( + Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CallInfoCard(name, info, isActive) + if (incoming && !isActive) { + IncomingCallActions(onCallAction) + } else { + OngoingCallActions( + isActive = isActive, + isOnHold = isOnHold, + isMuted = isMuted, + currentEndpoint = currentEndpoint, + endpoints = endpoints, + onCallAction = onCallAction, + onTransferCall = { + showTransferEndpoints = true + }, + ) + } + } + + // Show a picker when selecting to transfer a call + AnimatedVisibility(visible = showTransferEndpoints) { + TransferCallDialog( + transferEndpoints = transferEndpoints, + onDismissRequest = { + showTransferEndpoints = false + }, + onCallAction = { + showTransferEndpoints = false + onCallAction(it) + }, + ) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun OngoingCallActions( + isActive: Boolean, + isOnHold: Boolean, + isMuted: Boolean, + currentEndpoint: CallEndpointCompat?, + endpoints: List, + onCallAction: (TelecomCallAction) -> Unit, + onTransferCall: () -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .shadow(1.dp) + .padding(26.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CallControls( + isActive = isActive, + isOnHold = isOnHold, + isMuted = isMuted, + endpointType = currentEndpoint?.type ?: CallEndpointCompat.TYPE_UNKNOWN, + availableTypes = endpoints.map { it.type }, + onCallAction = onCallAction, + onTransferCall = onTransferCall, + ) + FloatingActionButton( + onClick = { + onCallAction( + TelecomCallAction.Disconnect( + DisconnectCause( + DisconnectCause.LOCAL, + ), + ), + ) + }, + containerColor = MaterialTheme.colorScheme.error, + ) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = null, + modifier = Modifier.rotate(90f), + ) + } + } +} + +@RequiresApi(Build.VERSION_CODES.M) +@Composable +private fun IncomingCallActions(onCallAction: (TelecomCallAction) -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(26.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + FloatingActionButton( + onClick = { + onCallAction( + TelecomCallAction.Disconnect( + DisconnectCause( + DisconnectCause.REJECTED, + ), + ), + ) + }, + containerColor = MaterialTheme.colorScheme.error, + ) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = null, + modifier = Modifier.rotate(90f), + ) + } + FloatingActionButton( + onClick = { + onCallAction( + TelecomCallAction.Answer, + ) + }, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon(imageVector = Icons.Rounded.Call, contentDescription = null) + } + } +} + +@Composable +private fun CallInfoCard(name: String, info: String, isActive: Boolean) { + Column( + Modifier + .fillMaxSize(0.5f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image(imageVector = Icons.Rounded.Person, contentDescription = null) + Text(text = name, style = MaterialTheme.typography.titleMedium) + Text(text = info, style = MaterialTheme.typography.bodyMedium) + + if (!isActive) { + Text(text = "Connecting...", style = MaterialTheme.typography.titleSmall) + } else { + Text(text = "Connected", style = MaterialTheme.typography.titleSmall) + } + + } +} + +/** + * Displays the call controls based on the current call attributes + */ +@Composable +private fun CallControls( + isActive: Boolean, + isOnHold: Boolean, + isMuted: Boolean, + endpointType: @CallEndpointCompat.Companion.EndpointType Int, + availableTypes: List<@CallEndpointCompat.Companion.EndpointType Int>, + onCallAction: (TelecomCallAction) -> Unit, + onTransferCall: () -> Unit, +) { + val isLocalCall = endpointType != CallEndpointCompat.TYPE_STREAMING + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + IconToggleButton( + checked = isMuted, + onCheckedChange = { + onCallAction(TelecomCallAction.ToggleMute(it)) + }, + ) { + if (isMuted) { + Icon(imageVector = Icons.Rounded.MicOff, contentDescription = "Mic on") + } else { + Icon(imageVector = Icons.Rounded.Mic, contentDescription = "Mic off") + } + } + IconToggleButton( + checked = endpointType == CallEndpointCompat.TYPE_SPEAKER, + enabled = isLocalCall, + onCheckedChange = { selected -> + val type = if (selected) { + CallEndpointCompat.TYPE_SPEAKER + } else { + // Switch to either wired headset or earpiece + availableTypes.firstOrNull { it == CallEndpointCompat.TYPE_WIRED_HEADSET } + ?: CallEndpointCompat.TYPE_EARPIECE + } + onCallAction(TelecomCallAction.SwitchAudioType(type)) + }, + ) { + Icon(imageVector = Icons.Rounded.VolumeUp, contentDescription = "Toggle speaker") + } + if (availableTypes.contains(CallEndpointCompat.TYPE_BLUETOOTH)) { + IconToggleButton( + checked = endpointType == CallEndpointCompat.TYPE_BLUETOOTH, + enabled = isLocalCall, + onCheckedChange = { selected -> + val type = if (selected) { + CallEndpointCompat.TYPE_BLUETOOTH + } else { + // Switch to the default endpoint (as defined in TelecomCallRepo) + availableTypes.firstOrNull { it == CallEndpointCompat.TYPE_WIRED_HEADSET } + ?: CallEndpointCompat.TYPE_EARPIECE + } + onCallAction(TelecomCallAction.SwitchAudioType(type)) + }, + ) { + if (endpointType == CallEndpointCompat.TYPE_BLUETOOTH) { + Icon( + imageVector = Icons.Rounded.BluetoothAudio, + contentDescription = "Disable bluetooth", + ) + } else { + Icon( + imageVector = Icons.Rounded.BluetoothDisabled, + contentDescription = "Enable bluetooth", + ) + } + } + } + IconToggleButton( + enabled = isActive, + checked = isOnHold, + onCheckedChange = { + val action = if (it) { + TelecomCallAction.Hold + } else { + TelecomCallAction.Activate + } + onCallAction(action) + }, + ) { + Icon( + imageVector = Icons.Rounded.PhonePaused, + contentDescription = "Pause or resume call", + ) + } + + if (availableTypes.contains(CallEndpointCompat.TYPE_STREAMING)) { + IconToggleButton( + enabled = isActive, + checked = !isLocalCall, + onCheckedChange = { + if (it) { + onTransferCall() + } else { + // Switch back to the default audio type + onCallAction(TelecomCallAction.SwitchAudioType(CallEndpointCompat.TYPE_UNKNOWN)) + } + }, + ) { + Icon( + imageVector = Icons.Rounded.PhoneForwarded, + contentDescription = "Transfer call", + ) + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun TransferCallDialog( + transferEndpoints: List, + onDismissRequest: () -> Unit, + onCallAction: (TelecomCallAction) -> Unit, +) { + AlertDialog(onDismissRequest = onDismissRequest) { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column(Modifier.padding(16.dp)) { + Text(text = "Where to transfer the call?") + LazyColumn { + items(transferEndpoints) { + Text( + text = it.name.toString(), + modifier = Modifier + .fillMaxWidth() + .clickable { + onCallAction(TelecomCallAction.TransferCall(it.identifier)) + }, + ) + } + } + Row(horizontalArrangement = Arrangement.End) { + OutlinedButton(onClick = onDismissRequest) { + Text(text = "Dismiss") + } + } + } + } + } +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt index f375baa9..77fe680a 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt @@ -22,8 +22,20 @@ import androidx.core.telecom.CallAttributesCompat import androidx.core.telecom.CallEndpointCompat import kotlinx.coroutines.channels.Channel +/** + * Custom representation of a call state. + */ sealed class TelecomCall { + + /** + * There is no current or past calls in the stack + */ object None : TelecomCall() + + /** + * Represents a registered call with the telecom stack with the values provided by the + * Telecom SDK + */ data class Registered( val id: ParcelUuid, val callAttributes: CallAttributesCompat, @@ -35,16 +47,22 @@ sealed class TelecomCall { internal val actionSource: Channel, ) : TelecomCall() { + /** + * @return true if it's an incoming registered call, false otherwise + */ fun isIncoming() = callAttributes.direction == CallAttributesCompat.DIRECTION_INCOMING /** * Sends an action to the call session. It will be processed if it's still registered. + * + * @return true if the action was sent, false otherwise */ - fun processAction(action: TelecomCallAction) { - actionSource.trySend(action) - } + fun processAction(action: TelecomCallAction) = actionSource.trySend(action).isSuccess } + /** + * Represent a previously registered call that was disconnected + */ data class Unregistered( val id: ParcelUuid, val callAttributes: CallAttributesCompat, diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt index b9b4ca2f..67654f0e 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt @@ -21,6 +21,13 @@ import android.os.Parcelable import android.telecom.DisconnectCause import kotlinx.parcelize.Parcelize +/** + * Simple interface to represent related call actions to communicate with the registered call scope + * in the [TelecomCallRepository.registerCall] + * + * Note: we are using [Parcelize] to make the actions parcelable so they can be directly used in the + * call notification. + */ sealed interface TelecomCallAction : Parcelable { @Parcelize object Answer : TelecomCallAction @@ -35,11 +42,11 @@ sealed interface TelecomCallAction : Parcelable { object Activate : TelecomCallAction @Parcelize - data class Mute(val isMute: Boolean) : TelecomCallAction + data class ToggleMute(val isMute: Boolean) : TelecomCallAction @Parcelize data class SwitchAudioType(val type: Int) : TelecomCallAction @Parcelize - data class TransferCall(val id: ParcelUuid) : TelecomCallAction + data class TransferCall(val endpointId: ParcelUuid) : TelecomCallAction } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt index 4d922635..214281de 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -40,6 +40,13 @@ import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +/** + * The central repository that keeps track of the current call and allows to register new calls. + * + * This class contains the main logic to integrate with Telecom SDK. + * + * @see registerCall + */ @RequiresApi(Build.VERSION_CODES.O) class TelecomCallRepository( private val applicationScope: CoroutineScope, @@ -209,7 +216,7 @@ class TelecomCallRepository( is TelecomCallAction.TransferCall -> { val call = _currentCall.value as? TelecomCall.Registered val endpoints = call?.availableCallEndpoints?.firstOrNull { - it.identifier == action.id + it.identifier == action.endpointId } requestEndpointChange( endpoint = endpoints ?: return@collect, @@ -231,7 +238,7 @@ class TelecomCallRepository( } } - is TelecomCallAction.Mute -> { + is TelecomCallAction.ToggleMute -> { // We cannot programmatically mute the telecom stack. Instead we just update // the state of the call and this will start/stop audio capturing. updateCurrentCall { From 581c08c715e0979ed0f30a22e7c7bb20ccfa60f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Fri, 14 Jul 2023 12:10:03 +0200 Subject: [PATCH 14/41] Add valid calling URIs Change-Id: Ic41d77f7fe61132bec888363778552505601a0ba --- .../telecom/TelecomCallSampleActivity.kt | 19 +++++++++++-------- .../connectivity/telecom/TelecomCallScreen.kt | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt index 9c606cd2..98edd90e 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt @@ -38,7 +38,7 @@ import com.google.android.catalog.framework.annotations.Sample @Sample( name = "Telecom Call Sample", description = "A sample showcasing how to handle calls with the Jetpack Telecom API", - documentation = "https://developer.android.com/guide/topics/connectivity/telecom" + documentation = "https://developer.android.com/guide/topics/connectivity/telecom", ) @RequiresApi(Build.VERSION_CODES.O) class TelecomCallSampleActivity : ComponentActivity() { @@ -58,16 +58,19 @@ class TelecomCallSampleActivity : ComponentActivity() { .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { + // To record the audio for the call + val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO) + // We should be using make_own_call permissions but this requires // implementation of the telecom API to work correctly. // Please see telecom example for full implementation - val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - listOf( - Manifest.permission.RECORD_AUDIO, - Manifest.permission.MANAGE_OWN_CALLS, - ) - } else { - listOf(Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + permissions.add(Manifest.permission.MANAGE_OWN_CALLS) + } + + // To show call notifications we need permissions since Android 13 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.POST_NOTIFICATIONS) } PermissionBox(permissions = permissions) { diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt index bf0c1006..f8dac51a 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt @@ -108,7 +108,7 @@ internal fun TelecomCallScreen(repository: TelecomCallRepository) { scope.launch(Dispatchers.IO) { repository.registerCall( displayName = "Alice", - address = Uri.parse(""), + address = Uri.parse("tel:12345"), isIncoming = true, ) } @@ -117,7 +117,7 @@ internal fun TelecomCallScreen(repository: TelecomCallRepository) { scope.launch(Dispatchers.IO) { repository.registerCall( displayName = "Bob", - address = Uri.parse(""), + address = Uri.parse("tel:54321"), isIncoming = false, ) From 014f201fa34f8699f4ee42de9317ac2ed493208d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Fri, 14 Jul 2023 15:28:42 +0200 Subject: [PATCH 15/41] Split into two notification channels To set different attributes for incoming and ongoing we need different channel Change-Id: I7bc8da0f3d511241d8fd7abba6ab3ac90263f216 --- .../telecom/TelecomCallNotificationManager.kt | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt index 646ea6d0..9bbc21ed 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt @@ -21,6 +21,8 @@ import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioManager import android.media.RingtoneManager import android.os.Build import android.telecom.DisconnectCause @@ -45,7 +47,8 @@ class TelecomCallNotificationManager(private val context: Context) { internal companion object { const val TELECOM_NOTIFICATION_ID = 200 const val TELECOM_NOTIFICATION_ACTION = "telecom_action" - const val TELECOM_NOTIFICATION_CHANNEL_ID = "telecom_channel" + const val TELECOM_NOTIFICATION_INCOMING_CHANNEL_ID = "telecom_incoming_channel" + const val TELECOM_NOTIFICATION_ONGOING_CHANNEL_ID = "telecom_ongoing_channel" private val ringToneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) } @@ -58,7 +61,7 @@ class TelecomCallNotificationManager(private val context: Context) { */ fun updateCallNotification(call: TelecomCall) { // If notifications are not granted, skip it. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS, @@ -68,7 +71,7 @@ class TelecomCallNotificationManager(private val context: Context) { } // Ensure that the channel is created - createNotificationChannel() + createNotificationChannels() // Update or dismiss notification when (call) { @@ -97,7 +100,8 @@ class TelecomCallNotificationManager(private val context: Context) { ) // Define the call style based on the call state and set the right actions - val callStyle = if (call.isIncoming() && !call.isActive) { + val isIncoming = call.isIncoming() && !call.isActive + val callStyle = if (isIncoming) { NotificationCompat.CallStyle.forIncomingCall( caller, getPendingIntent( @@ -117,18 +121,19 @@ class TelecomCallNotificationManager(private val context: Context) { ), ) } + val channelId = if (isIncoming) { + TELECOM_NOTIFICATION_INCOMING_CHANNEL_ID + } else { + TELECOM_NOTIFICATION_ONGOING_CHANNEL_ID + } - val builder = NotificationCompat.Builder(context, TELECOM_NOTIFICATION_CHANNEL_ID) + val builder = NotificationCompat.Builder(context, channelId) .setContentIntent(contentIntent) .setFullScreenIntent(contentIntent, true) .setSmallIcon(R.drawable.ic_round_call_24) .setOngoing(true) .setStyle(callStyle) - if (call.isIncoming()) { - builder.setSound(ringToneUri) - } - // TODO figure out why custom actions are not working if (call.isOnHold) { builder.addAction( @@ -165,13 +170,29 @@ class TelecomCallNotificationManager(private val context: Context) { ) } - private fun createNotificationChannel() { - val name = "Telecom Channel" - val descriptionText = "Handles the notifications when receiving or doing a call" - val channel = NotificationChannelCompat.Builder( - TELECOM_NOTIFICATION_CHANNEL_ID, + private fun createNotificationChannels() { + val incomingChannel = NotificationChannelCompat.Builder( + TELECOM_NOTIFICATION_INCOMING_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH, - ).setName(name).setDescription(descriptionText).build() - notificationManager.createNotificationChannel(channel) + ).setName("Incoming calls") + .setDescription("Handles the notifications when receiving a call") + .setVibrationEnabled(true).setSound( + ringToneUri, + AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setLegacyStreamType(AudioManager.STREAM_RING) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE).build(), + ).build() + + val ongoingChannel = NotificationChannelCompat.Builder( + TELECOM_NOTIFICATION_ONGOING_CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_DEFAULT, + ).setName("Ongoing calls").setDescription("Displays the ongoing call notifications").build() + + notificationManager.createNotificationChannelsCompat( + listOf( + incomingChannel, + ongoingChannel, + ), + ) } } From f0b2493eca8e0a722bca5e8668c552a0d040dc80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Mon, 17 Jul 2023 14:11:58 +0200 Subject: [PATCH 16/41] Update to latest telecom version with coroutine context scope Change-Id: I61eb256e93815ae78781bf806fe39d8267f88e8a --- .../telecom/model/TelecomCallRepository.kt | 192 +++++++++--------- settings.gradle.kts | 2 +- 2 files changed, 96 insertions(+), 98 deletions(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt index 214281de..3d683528 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -128,72 +128,79 @@ class TelecomCallRepository( // Fake incoming call delay delay(2000) } - // Register the call and handle actions in the scope - callsManager.addCall(attributes) { - // Register the callback to be notified about other call actions - // from other services or devices - // TODO this should eventually be moved inside the addCall method b/290562928 - setCallback( - object : CallControlCallback { - override suspend fun onAnswer(callType: Int): Boolean { - TODO("Not yet implemented") - } - - override suspend fun onDisconnect(disconnectCause: DisconnectCause): Boolean { - TODO("Not yet implemented") - } - - override suspend fun onSetActive(): Boolean { - TODO("Not yet implemented") - } - - override suspend fun onSetInactive(): Boolean { - TODO("Not yet implemented") - } - }, - ) - - launch { - processCallStatus() - } - - launch { - processCallActions(actionSource.consumeAsFlow()) - } + try { + // Register the call and handle actions in the scope + callsManager.addCall(attributes) { + // Register the callback to be notified about other call actions + // from other services or devices + // TODO this should eventually be moved inside the addCall method b/290562928 + setCallback( + object : CallControlCallback { + override suspend fun onAnswer(callType: Int): Boolean { + TODO("Not yet implemented") + } + + override suspend fun onDisconnect(disconnectCause: DisconnectCause): Boolean { + TODO("Not yet implemented") + } + + override suspend fun onSetActive(): Boolean { + TODO("Not yet implemented") + } + + override suspend fun onSetInactive(): Boolean { + TODO("Not yet implemented") + } + }, + ) + + launch { + processCallStatus() + } - // TODO Use the state value once b/290538853 is fixed - _currentCall.value = TelecomCall.Registered( - id = getCallId(), - isActive = false, - isOnHold = false, - callAttributes = attributes, - isMuted = false, - currentCallEndpoint = null, - availableCallEndpoints = emptyList(), - actionSource = actionSource, - ) + launch { + processCallActions(actionSource.consumeAsFlow()) + } - launch { - currentCallEndpoint.collect { - updateCurrentCall { - copy(currentCallEndpoint = it) + // TODO Use the state value once b/290538853 is fixed + _currentCall.value = TelecomCall.Registered( + id = getCallId(), + isActive = false, + isOnHold = false, + callAttributes = attributes, + isMuted = false, + currentCallEndpoint = null, + availableCallEndpoints = emptyList(), + actionSource = actionSource, + ) + + launch { + currentCallEndpoint.collect { + updateCurrentCall { + copy(currentCallEndpoint = it) + } } } - } - launch { - availableEndpoints.collect { - updateCurrentCall { - copy(availableCallEndpoints = it) + launch { + availableEndpoints.collect { + updateCurrentCall { + copy(availableCallEndpoints = it) + } } } - } - launch { - isMuted.collect { - updateCurrentCall { - copy(isMuted = it) + launch { + isMuted.collect { + updateCurrentCall { + copy(isMuted = it) + } } } } + } finally { + Log.d("MPB", "Exit scope") + _currentCall.update { + TelecomCall.None + } } } } @@ -202,58 +209,49 @@ class TelecomCallRepository( * Collect the action source to handle client actions inside the call scope */ private suspend fun CallControlScope.processCallActions(actionSource: Flow) { - try { - actionSource.collect { action -> - when (action) { - is TelecomCallAction.Answer -> doAnswer() + actionSource.collect { action -> + when (action) { + is TelecomCallAction.Answer -> doAnswer() - is TelecomCallAction.Disconnect -> { - doDisconnect(action) - } + is TelecomCallAction.Disconnect -> { + doDisconnect(action) + } - is TelecomCallAction.SwitchAudioType -> doSwitchEndpoint(action) + is TelecomCallAction.SwitchAudioType -> doSwitchEndpoint(action) - is TelecomCallAction.TransferCall -> { - val call = _currentCall.value as? TelecomCall.Registered - val endpoints = call?.availableCallEndpoints?.firstOrNull { - it.identifier == action.endpointId - } - requestEndpointChange( - endpoint = endpoints ?: return@collect, - ) + is TelecomCallAction.TransferCall -> { + val call = _currentCall.value as? TelecomCall.Registered + val endpoints = call?.availableCallEndpoints?.firstOrNull { + it.identifier == action.endpointId } + requestEndpointChange( + endpoint = endpoints ?: return@collect, + ) + } - TelecomCallAction.Hold -> if (setInactive()) { - updateCurrentCall { - copy(isOnHold = true) - } + TelecomCallAction.Hold -> if (setInactive()) { + updateCurrentCall { + copy(isOnHold = true) } + } - TelecomCallAction.Activate -> if (setActive()) { - updateCurrentCall { - copy( - isActive = true, - isOnHold = false, - ) - } + TelecomCallAction.Activate -> if (setActive()) { + updateCurrentCall { + copy( + isActive = true, + isOnHold = false, + ) } + } - is TelecomCallAction.ToggleMute -> { - // We cannot programmatically mute the telecom stack. Instead we just update - // the state of the call and this will start/stop audio capturing. - updateCurrentCall { - copy(isMuted = !isMuted) - } + is TelecomCallAction.ToggleMute -> { + // We cannot programmatically mute the telecom stack. Instead we just update + // the state of the call and this will start/stop audio capturing. + updateCurrentCall { + copy(isMuted = !isMuted) } } } - } finally { - // TODO this finally block should be when calling addCall once it implements - // the CoroutineContext - Log.d("MPB", "Exit scope") - _currentCall.update { - TelecomCall.None - } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 41990e65..2d2d7078 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,7 +30,7 @@ dependencyResolutionManagement { mavenCentral() // Using SNAPSHOTS for Telecom SDK. This should be removed once telecom SDK is stable - maven { url = uri("https://androidx.dev/snapshots/builds/10465142/artifacts/repository") } + maven { url = uri("https://androidx.dev/snapshots/builds/10506499/artifacts/repository") } } } From 14d464a7c2613e808024f12b2d1c23be15368f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Tue, 18 Jul 2023 13:49:02 +0200 Subject: [PATCH 17/41] Moved call logic into a service - The service handles now the call logic and holds the call scope - The repo now only handles Telecom SDK logic - Split into menu screen and actual call activity Change-Id: I27584554ea2d32a91914f48fd2f8b9bca3af8266 --- samples/README.md | 2 +- .../audio/datasource/AudioLoopSource.kt | 3 + samples/connectivity/telecom/build.gradle.kts | 1 + .../telecom/src/main/AndroidManifest.xml | 31 +-- .../connectivity/telecom/TelecomCallSample.kt | 144 +++++++++++++ .../TelecomCallActivity.kt} | 59 ++--- .../{ => call}/TelecomCallBroadcast.kt | 7 +- .../TelecomCallNotificationManager.kt | 30 +-- .../telecom/{ => call}/TelecomCallScreen.kt | 161 ++++++++------ .../telecom/call/TelecomCallService.kt | 165 ++++++++++++++ .../telecom/model/TelecomCallRepository.kt | 201 +++++++----------- 11 files changed, 550 insertions(+), 254 deletions(-) create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt rename samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/{TelecomCallSampleActivity.kt => call/TelecomCallActivity.kt} (65%) rename samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/{ => call}/TelecomCallBroadcast.kt (87%) rename samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/{ => call}/TelecomCallNotificationManager.kt (90%) rename samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/{ => call}/TelecomCallScreen.kt (82%) create mode 100644 samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt diff --git a/samples/README.md b/samples/README.md index 8a9b0c39..cafae5f2 100644 --- a/samples/README.md +++ b/samples/README.md @@ -76,7 +76,7 @@ This sample shows how to detect that the user capture the screen in Android 14 o Shows the recommended flow to request single runtime permissions - [Speakable Text](accessibility/src/main/java/com/example/platform/accessibility/SpeakableText.kt): The sample demonstrates the importance of having proper labels for -- [Telecom Call Sample](connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt): +- [Telecom Call Sample](connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt): A sample showcasing how to handle calls with the Jetpack Telecom API - [TextSpan](user-interface/text/src/main/java/com/example/platform/ui/text/TextSpan.kt): buildSpannedString is useful for quickly building a rich text. diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt index 9e2a9bb2..8f4bc40c 100644 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt @@ -25,6 +25,7 @@ import android.media.AudioRecord import android.media.AudioTrack import android.media.MediaRecorder import android.os.Build +import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -97,6 +98,7 @@ class AudioLoopSource { */ @SuppressLint("MissingPermission") fun startAudioLoop(): Boolean { + Log.d("MPB", "startAudioLoop") if (audioSampler == null) { audioSampler = AudioRecord( @@ -141,6 +143,7 @@ class AudioLoopSource { * Stops current job and releases microphone and audio devices */ fun stopAudioLoop() { + Log.d("MPB", "stopAudioLoop") job?.cancel("Stop Recording", null) isRecording.update { false } if (audioSampler?.state == AudioRecord.STATE_INITIALIZED) { diff --git a/samples/connectivity/telecom/build.gradle.kts b/samples/connectivity/telecom/build.gradle.kts index e24587d3..5a63b7a9 100644 --- a/samples/connectivity/telecom/build.gradle.kts +++ b/samples/connectivity/telecom/build.gradle.kts @@ -18,6 +18,7 @@ plugins { id("com.example.platform.sample") + alias(libs.plugins.kotlin.android) } android { diff --git a/samples/connectivity/telecom/src/main/AndroidManifest.xml b/samples/connectivity/telecom/src/main/AndroidManifest.xml index 0b65a4a3..bc7d46df 100644 --- a/samples/connectivity/telecom/src/main/AndroidManifest.xml +++ b/samples/connectivity/telecom/src/main/AndroidManifest.xml @@ -1,19 +1,4 @@ - - + @@ -22,19 +7,21 @@ + + - + android:turnScreenOn="true" /> - diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt new file mode 100644 index 00000000..88cd42c0 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.example.platform.base.PermissionBox +import com.example.platform.connectivity.telecom.call.TelecomCallActivity +import com.example.platform.connectivity.telecom.call.TelecomCallService +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallRepository +import com.google.android.catalog.framework.annotations.Sample + +@Sample( + name = "Telecom Call Sample", + description = "A sample showcasing how to handle calls with the Jetpack Telecom API", + documentation = "https://developer.android.com/guide/topics/connectivity/telecom", +) +@RequiresApi(Build.VERSION_CODES.O) +@Composable +fun TelecomCallSample() { + // To record the audio for the call + val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO) + + // We should be using make_own_call permissions but this requires + // implementation of the telecom API to work correctly. + // Please see telecom example for full implementation + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + permissions.add(Manifest.permission.MANAGE_OWN_CALLS) + } + + // To show call notifications we need permissions since Android 13 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.POST_NOTIFICATIONS) + } + + PermissionBox(permissions = permissions) { + TelecomCallOptions() + } +} + +/** + * Screen to launch incoming and outgoing calls. It would normally be the dialer. + */ +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun TelecomCallOptions() { + val context = LocalContext.current + val repository = remember { + TelecomCallRepository.instance ?: TelecomCallRepository.create(context.applicationContext) + } + val call by repository.currentCall.collectAsState() + val hasOngoingCall = call is TelecomCall.Registered + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val title = if (hasOngoingCall) { + "There is an active call" + } else { + "No active call" + } + Text(text = title, style = MaterialTheme.typography.titleLarge) + Button( + enabled = !hasOngoingCall, + onClick = { + Toast.makeText(context, "Incoming call in 2 seconds", Toast.LENGTH_SHORT).show() + context.launchCall( + action = TelecomCallService.ACTION_INCOMING_CALL, + name = "Alice", + uri = Uri.parse("tel:12345"), + ) + }, + ) { + Text(text = "Receive fake call") + } + Button( + enabled = !hasOngoingCall, + onClick = { + context.launchCall( + action = TelecomCallService.ACTION_OUTGOING_CALL, + name = "Bob", + uri = Uri.parse("tel:54321"), + ) + context.startActivity( + Intent(context, TelecomCallActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + }, + ) { + Text(text = "Make fake call") + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun Context.launchCall(action: String, name: String, uri: Uri) { + startService( + Intent(this, TelecomCallService::class.java).apply { + this.action = action + putExtra(TelecomCallService.EXTRA_NAME, name) + putExtra(TelecomCallService.EXTRA_URI, uri) + }, + ) +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt similarity index 65% rename from samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt rename to samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt index 98edd90e..2d1f5400 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSampleActivity.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package com.example.platform.connectivity.telecom +package com.example.platform.connectivity.telecom.call -import android.Manifest import android.app.KeyguardManager +import android.content.Intent import android.os.Build import android.os.Bundle import android.view.WindowManager @@ -28,29 +28,30 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.core.content.getSystemService -import com.example.platform.base.PermissionBox import com.example.platform.connectivity.telecom.model.TelecomCallRepository -import com.google.android.catalog.framework.annotations.Sample +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch -@Sample( - name = "Telecom Call Sample", - description = "A sample showcasing how to handle calls with the Jetpack Telecom API", - documentation = "https://developer.android.com/guide/topics/connectivity/telecom", -) +/** + * This activity is used to launch the incoming or ongoing call. It uses special flags to be able + * to be launched in the lockscreen and as a full-screen notification. + */ @RequiresApi(Build.VERSION_CODES.O) -class TelecomCallSampleActivity : ComponentActivity() { +class TelecomCallActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setupCallActivity() - // The repo contains all the call logic and communication with the Telecom SDK. val repository = TelecomCallRepository.instance ?: TelecomCallRepository.create(applicationContext) + // Set the right flags for a call type activity. + setupCallActivity() + setContent { MaterialTheme { Surface( @@ -58,29 +59,31 @@ class TelecomCallSampleActivity : ComponentActivity() { .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - // To record the audio for the call - val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO) + val scope = rememberCoroutineScope() - // We should be using make_own_call permissions but this requires - // implementation of the telecom API to work correctly. - // Please see telecom example for full implementation - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - permissions.add(Manifest.permission.MANAGE_OWN_CALLS) - } - - // To show call notifications we need permissions since Android 13 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissions.add(Manifest.permission.POST_NOTIFICATIONS) - } - - PermissionBox(permissions = permissions) { - TelecomCallScreen(repository) + // Show the in-call screen + TelecomCallScreen(repository) { + // If we receive that the called finished, finish the activity + scope.launch { + delay(1500) + finish() + } } } } } } + override fun onResume() { + super.onResume() + // Force the service to update in case something change like Mic permissions. + startService( + Intent(this, TelecomCallService::class.java).apply { + action = TelecomCallService.ACTION_UPDATE_CALL + }, + ) + } + /** * Enable the calling activity to be shown in the lockscreen and dismiss the keyguard to enable * users to answer without unblocking. diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallBroadcast.kt similarity index 87% rename from samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt rename to samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallBroadcast.kt index 5c0d2ae8..62705083 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallBroadcast.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallBroadcast.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.platform.connectivity.telecom +package com.example.platform.connectivity.telecom.call import android.content.BroadcastReceiver import android.content.Context @@ -37,9 +37,12 @@ class TelecomCallBroadcast : BroadcastReceiver() { val repo = TelecomCallRepository.instance ?: TelecomCallRepository.create(context) val call = repo.currentCall.value - // If the call is still registered perform action if (call is TelecomCall.Registered) { + // If the call is still registered perform action call.processAction(action) + } else { + // Otherwise probably something went wrong and the notification is wrong. + TelecomCallNotificationManager(context).updateCallNotification(call) } } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallNotificationManager.kt similarity index 90% rename from samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt rename to samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallNotificationManager.kt index 9bbc21ed..25f3689b 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallNotificationManager.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallNotificationManager.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package com.example.platform.connectivity.telecom +package com.example.platform.connectivity.telecom.call import android.Manifest -import android.annotation.SuppressLint +import android.app.Notification import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -27,12 +27,12 @@ import android.media.RingtoneManager import android.os.Build import android.telecom.DisconnectCause import androidx.annotation.RequiresApi -import androidx.annotation.RequiresPermission import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.content.PermissionChecker +import com.example.platform.connectivity.telecom.R import com.example.platform.connectivity.telecom.model.TelecomCall import com.example.platform.connectivity.telecom.model.TelecomCallAction @@ -44,6 +44,7 @@ import com.example.platform.connectivity.telecom.model.TelecomCallAction */ @RequiresApi(Build.VERSION_CODES.O) class TelecomCallNotificationManager(private val context: Context) { + internal companion object { const val TELECOM_NOTIFICATION_ID = 200 const val TELECOM_NOTIFICATION_ACTION = "telecom_action" @@ -75,14 +76,18 @@ class TelecomCallNotificationManager(private val context: Context) { // Update or dismiss notification when (call) { - TelecomCall.None, is TelecomCall.Unregistered -> cancelNotification() - is TelecomCall.Registered -> updateNotification(call) + TelecomCall.None, is TelecomCall.Unregistered -> { + notificationManager.cancel(TELECOM_NOTIFICATION_ID) + } + + is TelecomCall.Registered -> { + val notification = createNotification(call) + notificationManager.notify(TELECOM_NOTIFICATION_ID, notification) + } } } - @SuppressLint("InlinedApi") - @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) - private fun updateNotification(call: TelecomCall.Registered) { + private fun createNotification(call: TelecomCall.Registered): Notification { // To display the caller information val caller = Person.Builder() .setName(call.callAttributes.displayName) @@ -95,7 +100,7 @@ class TelecomCallNotificationManager(private val context: Context) { val contentIntent = PendingIntent.getActivity( /* context = */ context, /* requestCode = */ 0, - /* intent = */ Intent(context, TelecomCallSampleActivity::class.java), + /* intent = */ Intent(context, TelecomCallActivity::class.java), /* flags = */ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -143,12 +148,7 @@ class TelecomCallNotificationManager(private val context: Context) { ), ) } - - notificationManager.notify(TELECOM_NOTIFICATION_ID, builder.build()) - } - - private fun cancelNotification() { - notificationManager.cancel(TELECOM_NOTIFICATION_ID) + return builder.build() } /** diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt similarity index 82% rename from samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt rename to samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt index f8dac51a..77fd0b9e 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt @@ -14,18 +14,18 @@ * limitations under the License. */ -package com.example.platform.connectivity.telecom +package com.example.platform.connectivity.telecom.call -import android.net.Uri +import android.Manifest import android.os.Build import android.telecom.DisconnectCause -import android.widget.Toast import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -47,36 +47,37 @@ import androidx.compose.material.icons.rounded.PhonePaused import androidx.compose.material.icons.rounded.VolumeUp import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.telecom.CallEndpointCompat import com.example.platform.connectivity.telecom.model.TelecomCall import com.example.platform.connectivity.telecom.model.TelecomCallAction import com.example.platform.connectivity.telecom.model.TelecomCallRepository -import kotlinx.coroutines.Dispatchers +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale import kotlinx.coroutines.delay -import kotlinx.coroutines.launch /** * This composable observes the current state of the call and updates the UI based on its attributes @@ -85,52 +86,30 @@ import kotlinx.coroutines.launch */ @RequiresApi(Build.VERSION_CODES.O) @Composable -internal fun TelecomCallScreen(repository: TelecomCallRepository) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - +internal fun TelecomCallScreen(repository: TelecomCallRepository, onCallFinished: () -> Unit) { // Collect the current call state and update UI val call by repository.currentCall.collectAsState() - // If call goes unregistered inform user + // If doing an outgoing call, fake the other end picks it up (this is only for this sample). + (call as? TelecomCall.Registered)?.run { + if (!isIncoming() && !isActive) { + LaunchedEffect(Unit) { + delay(2000) + processAction(TelecomCallAction.Activate) + } + } + } + if (call is TelecomCall.Unregistered) { LaunchedEffect(Unit) { - Toast.makeText(context, "Call disconnected", Toast.LENGTH_SHORT).show() + onCallFinished() } } when (val newCall = call) { is TelecomCall.Unregistered, TelecomCall.None -> { - // Show calling menu when there is no active call - NoCallScreen( - incomingCall = { - Toast.makeText(context, "Incoming call in 2 seconds", Toast.LENGTH_SHORT).show() - scope.launch(Dispatchers.IO) { - repository.registerCall( - displayName = "Alice", - address = Uri.parse("tel:12345"), - isIncoming = true, - ) - } - }, - outgoingCall = { - scope.launch(Dispatchers.IO) { - repository.registerCall( - displayName = "Bob", - address = Uri.parse("tel:54321"), - isIncoming = false, - ) - - // Faking that the other end is not picking it - delay(2000) - - // The other end answered, activate the call - (repository.currentCall.value as? TelecomCall.Registered)?.processAction( - TelecomCallAction.Activate, - ) - } - }, - ) + // Show call ended when there is no active call + NoCallScreen() } is TelecomCall.Registered -> { @@ -152,21 +131,14 @@ internal fun TelecomCallScreen(repository: TelecomCallRepository) { } @Composable -private fun NoCallScreen(incomingCall: () -> Unit, outgoingCall: () -> Unit) { - Column( +private fun NoCallScreen() { + Box( modifier = Modifier .fillMaxSize() .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, + contentAlignment = Alignment.Center, ) { - Text(text = "No active call", style = MaterialTheme.typography.titleLarge) - Button(onClick = incomingCall) { - Text(text = "Receive fake call") - } - Button(onClick = outgoingCall) { - Text(text = "Make fake call") - } + Text(text = "Call ended", style = MaterialTheme.typography.titleLarge) } } @@ -342,6 +314,7 @@ private fun CallInfoCard(name: String, info: String, isActive: Boolean) { /** * Displays the call controls based on the current call attributes */ +@OptIn(ExperimentalPermissionsApi::class) @Composable private fun CallControls( isActive: Boolean, @@ -353,22 +326,45 @@ private fun CallControls( onTransferCall: () -> Unit, ) { val isLocalCall = endpointType != CallEndpointCompat.TYPE_STREAMING + val micPermission = rememberPermissionState(permission = Manifest.permission.RECORD_AUDIO) + var showRational by remember(micPermission.status) { + mutableStateOf(false) + } + Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { - IconToggleButton( - checked = isMuted, - onCheckedChange = { - onCallAction(TelecomCallAction.ToggleMute(it)) - }, - ) { - if (isMuted) { - Icon(imageVector = Icons.Rounded.MicOff, contentDescription = "Mic on") - } else { - Icon(imageVector = Icons.Rounded.Mic, contentDescription = "Mic off") + if (micPermission.status.isGranted) { + IconToggleButton( + checked = isMuted, + onCheckedChange = { + onCallAction(TelecomCallAction.ToggleMute(it)) + }, + ) { + if (isMuted) { + Icon(imageVector = Icons.Rounded.MicOff, contentDescription = "Mic on") + } else { + Icon(imageVector = Icons.Rounded.Mic, contentDescription = "Mic off") + } + } + } else { + IconButton( + onClick = { + if (micPermission.status.shouldShowRationale) { + showRational = true + } else { + micPermission.launchPermissionRequest() + } + }, + ) { + Icon( + imageVector = Icons.Rounded.MicOff, + contentDescription = "Missing mic permission", + tint = MaterialTheme.colorScheme.error, + ) } } IconToggleButton( @@ -453,6 +449,18 @@ private fun CallControls( } } } + + // Show a rational dialog if user didn't accepted the permissions + if (showRational) { + RationalMicDialog( + onResult = { request -> + if (request) { + micPermission.launchPermissionRequest() + } + showRational = false + }, + ) + } } @RequiresApi(Build.VERSION_CODES.O) @@ -494,3 +502,26 @@ private fun TransferCallDialog( } } } + +@Composable +private fun RationalMicDialog(onResult: (Boolean) -> Unit) { + AlertDialog( + onDismissRequest = { onResult(false) }, + confirmButton = { + TextButton(onClick = { onResult(true) }) { + Text(text = "Continue") + } + }, + dismissButton = { + TextButton(onClick = { onResult(false) }) { + Text(text = "Cancel") + } + }, + title = { + Text(text = "Mic permission required") + }, + text = { + Text(text = "In order to speak in a call we need mic permission. Please press continue and grant the permission in the next dialog.") + }, + ) +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt new file mode 100644 index 00000000..3f492013 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.call + +import android.Manifest +import android.app.Service +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.IBinder +import androidx.annotation.RequiresApi +import androidx.core.content.PermissionChecker +import com.example.platform.connectivity.audio.datasource.AudioLoopSource +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + + +/** + * This service handles the app call logic (show notification, record mic, display audio, etc..). + * It can get started by the user or by an upcoming push notification to start a call. + * + * It holds the call scope used to register a call with the Telecom SDK in our TelecomCallRepository. + * + * When registering a call with the Telecom SDK and displaying a CallStyle notification, the SDK will + * grant you foreground service delegation so there is no need to make this a FGS. + * + * Note: you could potentially make this service run in a different process since audio or video + * calls can consume significant memory, although that would require more complex setup to make it + * work across multiple process. + */ +@RequiresApi(Build.VERSION_CODES.O) +class TelecomCallService : Service() { + + companion object { + internal const val EXTRA_NAME: String = "extra_name" + internal const val EXTRA_URI: String = "extra_uri" + internal const val ACTION_INCOMING_CALL = "incoming_call" + internal const val ACTION_OUTGOING_CALL = "outgoing_call" + internal const val ACTION_UPDATE_CALL = "update_call" + } + + private lateinit var notificationManager: TelecomCallNotificationManager + private lateinit var telecomRepository: TelecomCallRepository + + private val audioLoopSource = AudioLoopSource() + private val scope: CoroutineScope = CoroutineScope(SupervisorJob()) + + override fun onCreate() { + super.onCreate() + notificationManager = TelecomCallNotificationManager(applicationContext) + telecomRepository = + TelecomCallRepository.instance ?: TelecomCallRepository.create(applicationContext) + + // Observe call status updates once the call is registered and update the service + telecomRepository.currentCall + .dropWhile { + it is TelecomCall.None + } + .onEach { call -> + updateServiceState(call) + } + .onCompletion { + // If the scope is completed stop the service + stopSelf() + } + .launchIn(scope) + } + + override fun onDestroy() { + super.onDestroy() + // Remove notification and clean resources + scope.cancel() + audioLoopSource.stopAudioLoop() + notificationManager.updateCallNotification(TelecomCall.None) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) { + return START_NOT_STICKY + } + + when (intent.action) { + ACTION_INCOMING_CALL -> registerCall(intent = intent, incoming = true) + ACTION_OUTGOING_CALL -> registerCall(intent = intent, incoming = false) + ACTION_UPDATE_CALL -> updateServiceState(telecomRepository.currentCall.value) + + else -> throw IllegalArgumentException("Unknown action") + } + + return START_STICKY + } + + private fun registerCall(intent: Intent, incoming: Boolean) { + // If we have an ongoing call ignore command + if (telecomRepository.currentCall.value is TelecomCall.Registered) { + return + } + + val name = intent.getStringExtra(EXTRA_NAME)!! + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(EXTRA_URI, Uri::class.java)!! + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(EXTRA_URI)!! + } + + scope.launch { + if (incoming) { + // fake a delay for the incoming call for demo purposes + delay(2000) + } + telecomRepository.registerCall(name, uri, incoming) + } + } + + private fun updateServiceState(call: TelecomCall) { + // Update the call notification + notificationManager.updateCallNotification(call) + + if (call is TelecomCall.Registered) { + // Update the call audio. + // For this sample it means start/stop the audio loop + if (call.isActive && !call.isOnHold && !call.isMuted && hasMicPermission()) { + audioLoopSource.startAudioLoop() + } else { + audioLoopSource.stopAudioLoop() + } + } else { + // Stop the service and clean resources + stopSelf() + } + } + + override fun onBind(intent: Intent): IBinder? = null + + private fun hasMicPermission() = + PermissionChecker.checkSelfPermission( + this, + Manifest.permission.RECORD_AUDIO, + ) == PermissionChecker.PERMISSION_GRANTED + +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt index 3d683528..ef03b249 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -27,12 +27,7 @@ import androidx.core.telecom.CallControlCallback import androidx.core.telecom.CallControlScope import androidx.core.telecom.CallEndpointCompat import androidx.core.telecom.CallsManager -import com.example.platform.connectivity.audio.datasource.AudioLoopSource -import com.example.platform.connectivity.telecom.TelecomCallNotificationManager -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -48,12 +43,7 @@ import kotlinx.coroutines.launch * @see registerCall */ @RequiresApi(Build.VERSION_CODES.O) -class TelecomCallRepository( - private val applicationScope: CoroutineScope, - private val callsManager: CallsManager, - private val audioLoopSource: AudioLoopSource, - private val notificationManager: TelecomCallNotificationManager, -) { +class TelecomCallRepository(private val callsManager: CallsManager) { companion object { var instance: TelecomCallRepository? = null @@ -79,10 +69,7 @@ class TelecomCallRepository( } return TelecomCallRepository( - applicationScope = CoroutineScope(SupervisorJob()), callsManager = callsManager, - audioLoopSource = AudioLoopSource(), - notificationManager = TelecomCallNotificationManager(context), ).also { instance = it } @@ -97,7 +84,7 @@ class TelecomCallRepository( * Register a new call with the provided attributes. * Use the [currentCall] StateFlow to receive status updates and process call related actions. */ - fun registerCall(displayName: String, address: Uri, isIncoming: Boolean) { + suspend fun registerCall(displayName: String, address: Uri, isIncoming: Boolean) { // For simplicity we don't support multiple calls check(_currentCall.value !is TelecomCall.Registered) { "There cannot be more than one call at the same time." @@ -121,85 +108,74 @@ class TelecomCallRepository( // Creates a channel to send actions to the call scope. val actionSource = Channel() - // We launch the call in our application scope so it keeps registered while the app is alive - // or until the user explicitly disconnects it. - applicationScope.launch { - if (isIncoming) { - // Fake incoming call delay - delay(2000) - } - try { - // Register the call and handle actions in the scope - callsManager.addCall(attributes) { - // Register the callback to be notified about other call actions - // from other services or devices - // TODO this should eventually be moved inside the addCall method b/290562928 - setCallback( - object : CallControlCallback { - override suspend fun onAnswer(callType: Int): Boolean { - TODO("Not yet implemented") - } - - override suspend fun onDisconnect(disconnectCause: DisconnectCause): Boolean { - TODO("Not yet implemented") - } - - override suspend fun onSetActive(): Boolean { - TODO("Not yet implemented") - } - - override suspend fun onSetInactive(): Boolean { - TODO("Not yet implemented") - } - }, - ) - - launch { - processCallStatus() + // Register the call and handle actions in the scope + callsManager.addCall(attributes) { + // TODO this should eventually be moved inside the addCall method b/290562928 + setCallback( + // Register the callback to be notified about other call actions + // from other services or devices + object : CallControlCallback { + override suspend fun onAnswer(callType: Int): Boolean { + TODO("Not yet implemented") } - launch { - processCallActions(actionSource.consumeAsFlow()) + override suspend fun onDisconnect(disconnectCause: DisconnectCause): Boolean { + TODO("Not yet implemented") } - // TODO Use the state value once b/290538853 is fixed - _currentCall.value = TelecomCall.Registered( - id = getCallId(), - isActive = false, - isOnHold = false, - callAttributes = attributes, - isMuted = false, - currentCallEndpoint = null, - availableCallEndpoints = emptyList(), - actionSource = actionSource, - ) + override suspend fun onSetActive(): Boolean { + TODO("Not yet implemented") + } - launch { - currentCallEndpoint.collect { - updateCurrentCall { - copy(currentCallEndpoint = it) - } - } + override suspend fun onSetInactive(): Boolean { + TODO("Not yet implemented") } - launch { - availableEndpoints.collect { - updateCurrentCall { - copy(availableCallEndpoints = it) - } - } + }, + ) + + // Consume the actions to interact with the call inside the scope + launch { + try { + processCallActions(actionSource.consumeAsFlow()) + } finally { + // TODO this should wrap addCall once b/291604411 is fixed + _currentCall.update { + TelecomCall.None } - launch { - isMuted.collect { - updateCurrentCall { - copy(isMuted = it) - } - } + } + } + + // Update the state to registered with default values while waiting for Telecom updates + _currentCall.value = TelecomCall.Registered( + id = getCallId(), + isActive = false, + isOnHold = false, + callAttributes = attributes, + isMuted = false, + currentCallEndpoint = null, + availableCallEndpoints = emptyList(), + actionSource = actionSource, + ) + + launch { + currentCallEndpoint.collect { + updateCurrentCall { + copy(currentCallEndpoint = it) } } - } finally { - Log.d("MPB", "Exit scope") - _currentCall.update { - TelecomCall.None + } + launch { + availableEndpoints.collect { + updateCurrentCall { + copy(availableCallEndpoints = it) + } + } + } + launch { + isMuted.collect { + updateCurrentCall { + copy(isMuted = it) + } } } } @@ -211,13 +187,17 @@ class TelecomCallRepository( private suspend fun CallControlScope.processCallActions(actionSource: Flow) { actionSource.collect { action -> when (action) { - is TelecomCallAction.Answer -> doAnswer() + is TelecomCallAction.Answer -> { + doAnswer() + } is TelecomCallAction.Disconnect -> { doDisconnect(action) } - is TelecomCallAction.SwitchAudioType -> doSwitchEndpoint(action) + is TelecomCallAction.SwitchAudioType -> { + doSwitchEndpoint(action) + } is TelecomCallAction.TransferCall -> { val call = _currentCall.value as? TelecomCall.Registered @@ -229,18 +209,22 @@ class TelecomCallRepository( ) } - TelecomCallAction.Hold -> if (setInactive()) { - updateCurrentCall { - copy(isOnHold = true) + TelecomCallAction.Hold -> { + if (setInactive()) { + updateCurrentCall { + copy(isOnHold = true) + } } } - TelecomCallAction.Activate -> if (setActive()) { - updateCurrentCall { - copy( - isActive = true, - isOnHold = false, - ) + TelecomCallAction.Activate -> { + if (setActive()) { + updateCurrentCall { + copy( + isActive = true, + isOnHold = false, + ) + } } } @@ -255,31 +239,6 @@ class TelecomCallRepository( } } - /** - * Collects changes in the call status to coordinate call related actors like showing the - * notification for the call, start/stop audio... - */ - private suspend fun processCallStatus() { - _currentCall.collect { call -> - Log.d("MPB", "Call status changed: $call") - notificationManager.updateCallNotification(call) - - when (call) { - TelecomCall.None, is TelecomCall.Unregistered -> { - audioLoopSource.stopAudioLoop() - } - - is TelecomCall.Registered -> { - if (call.isActive && !call.isOnHold && !call.isMuted) { - audioLoopSource.startAudioLoop() - } else { - audioLoopSource.stopAudioLoop() - } - } - } - } - } - /** * Update the current state of our call applying the transform lambda only if the call is * registered. Otherwise keep the current state From f97566bb878c64102228ebbc07ee3c28a8d3b13b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Tue, 18 Jul 2023 15:39:02 +0200 Subject: [PATCH 18/41] Update README.md Change-Id: I837de3cca439aa213ca33e1cf4a2e47e27c9a0fa --- samples/connectivity/telecom/README.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/samples/connectivity/telecom/README.md b/samples/connectivity/telecom/README.md index b84119a4..171643d0 100644 --- a/samples/connectivity/telecom/README.md +++ b/samples/connectivity/telecom/README.md @@ -1,4 +1,19 @@ -# TelecomSample samples +# Telecom Sample -// TODO: provide minimal instructions -``` \ No newline at end of file +This module contains the sample for integrating Jetpack Telecom SDK to do audio and/or video calls +using the Android Telecom stack. + +The sample simulates a caller app that can make ongoing calls and receive incoming calls. There is +no actual call being made, the sample uses [AudioLoopSource](https://github.com/android/platform-samples/blob/14d464a7c2613e808024f12b2d1c23be15368f4e/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt) +to capture the audio in the device and loop it back to the active endpoint (e.g speaker) + +The structure of the sample is the following: + +- [TelecomCallSample](src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt): +The entry point of the sample with the options to perform a call or to fake an incoming call +- [TelecomCallActivity](src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt): +The activity to be launch when there is an active call. It handles the UI logic based on the current call. +- [TelecomCallService](src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt): +A service that manage the logic of the call and communicates with the Telecom SDK +- [TelecomCallRepository](src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt): +The actual logic to communicate with the Telecom SDK From c3a416abf268fae72da85be73d7c571543db109b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Wed, 19 Jul 2023 09:25:43 +0200 Subject: [PATCH 19/41] Remove task when call ended Change-Id: I7fbe5c95e72c1451ee56ce2828db9e95ed46a75e --- .../platform/connectivity/telecom/call/TelecomCallActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt index 2d1f5400..6167323f 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt @@ -66,7 +66,7 @@ class TelecomCallActivity : ComponentActivity() { // If we receive that the called finished, finish the activity scope.launch { delay(1500) - finish() + finishAndRemoveTask() } } } From e587354822507adfbed341368a6e63733f7c2831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Wed, 19 Jul 2023 09:26:31 +0200 Subject: [PATCH 20/41] Properly update state - Only stop the service once we receive the unregistered call - The none is only the init state. Change-Id: Id266bab9943ce375c19b17cbbc906070e3575952 --- .../telecom/call/TelecomCallService.kt | 30 ++++++++++--------- .../telecom/model/TelecomCallRepository.kt | 8 +++-- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt index 3f492013..0b3a3532 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach @@ -76,9 +75,6 @@ class TelecomCallService : Service() { // Observe call status updates once the call is registered and update the service telecomRepository.currentCall - .dropWhile { - it is TelecomCall.None - } .onEach { call -> updateServiceState(call) } @@ -140,17 +136,23 @@ class TelecomCallService : Service() { // Update the call notification notificationManager.updateCallNotification(call) - if (call is TelecomCall.Registered) { - // Update the call audio. - // For this sample it means start/stop the audio loop - if (call.isActive && !call.isOnHold && !call.isMuted && hasMicPermission()) { - audioLoopSource.startAudioLoop() - } else { - audioLoopSource.stopAudioLoop() + when (call) { + is TelecomCall.None -> audioLoopSource.stopAudioLoop() + + is TelecomCall.Registered -> { + // Update the call audio. + // For this sample it means start/stop the audio loop + if (call.isActive && !call.isOnHold && !call.isMuted && hasMicPermission()) { + audioLoopSource.startAudioLoop() + } else { + audioLoopSource.stopAudioLoop() + } + } + + is TelecomCall.Unregistered -> { + // Stop the service and clean resources + stopSelf() } - } else { - // Stop the service and clean resources - stopSelf() } } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt index ef03b249..9d5f59f3 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -139,8 +139,12 @@ class TelecomCallRepository(private val callsManager: CallsManager) { processCallActions(actionSource.consumeAsFlow()) } finally { // TODO this should wrap addCall once b/291604411 is fixed - _currentCall.update { - TelecomCall.None + updateCurrentCall { + TelecomCall.Unregistered( + id = id, + callAttributes = callAttributes, + disconnectCause = DisconnectCause(DisconnectCause.CANCELED), + ) } } } From 66f4055e3ee423dc3eb539c1fbd86da7c3ebc21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Wed, 19 Jul 2023 10:32:19 +0200 Subject: [PATCH 21/41] Only update process update action if registered call The update action is needed to retrieve state changes in the activity lifecycle, like permissions, but that's only important if we have an ongoing call. Change-Id: I8fa3e2666ddf2e003a551210bb765b5296298936 --- .../connectivity/telecom/call/TelecomCallService.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt index 0b3a3532..e2e60446 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt @@ -101,7 +101,12 @@ class TelecomCallService : Service() { when (intent.action) { ACTION_INCOMING_CALL -> registerCall(intent = intent, incoming = true) ACTION_OUTGOING_CALL -> registerCall(intent = intent, incoming = false) - ACTION_UPDATE_CALL -> updateServiceState(telecomRepository.currentCall.value) + ACTION_UPDATE_CALL -> { + val call = telecomRepository.currentCall.value + if (call is TelecomCall.Registered) { + updateServiceState(call) + } + } else -> throw IllegalArgumentException("Unknown action") } From 2372ca0b87d1575fb9a850672653fba2da78dc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Wed, 19 Jul 2023 10:32:53 +0200 Subject: [PATCH 22/41] Move demo logic outside of UI Change-Id: I9e2f901fa0b2e20818897e7fc16cfd5169d8b648 --- .../telecom/call/TelecomCallActivity.kt | 10 +-------- .../telecom/call/TelecomCallScreen.kt | 22 +++++-------------- .../telecom/call/TelecomCallService.kt | 17 +++++++++++++- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt index 6167323f..ebb1dec6 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt @@ -28,12 +28,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.core.content.getSystemService import com.example.platform.connectivity.telecom.model.TelecomCallRepository -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch /** @@ -59,15 +56,10 @@ class TelecomCallActivity : ComponentActivity() { .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - val scope = rememberCoroutineScope() - // Show the in-call screen TelecomCallScreen(repository) { // If we receive that the called finished, finish the activity - scope.launch { - delay(1500) - finishAndRemoveTask() - } + finishAndRemoveTask() } } } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt index 77fd0b9e..d05a0378 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt @@ -89,25 +89,13 @@ import kotlinx.coroutines.delay internal fun TelecomCallScreen(repository: TelecomCallRepository, onCallFinished: () -> Unit) { // Collect the current call state and update UI val call by repository.currentCall.collectAsState() - - // If doing an outgoing call, fake the other end picks it up (this is only for this sample). - (call as? TelecomCall.Registered)?.run { - if (!isIncoming() && !isActive) { - LaunchedEffect(Unit) { - delay(2000) - processAction(TelecomCallAction.Activate) - } - } - } - - if (call is TelecomCall.Unregistered) { - LaunchedEffect(Unit) { - onCallFinished() - } - } - when (val newCall = call) { is TelecomCall.Unregistered, TelecomCall.None -> { + // If there is no call invoke finish after a small delay + LaunchedEffect(Unit) { + delay(1500) + onCallFinished() + } // Show call ended when there is no active call NoCallScreen() } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt index e2e60446..3635c0c3 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt @@ -26,6 +26,7 @@ import androidx.annotation.RequiresApi import androidx.core.content.PermissionChecker import com.example.platform.connectivity.audio.datasource.AudioLoopSource import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallAction import com.example.platform.connectivity.telecom.model.TelecomCallRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -133,10 +134,24 @@ class TelecomCallService : Service() { // fake a delay for the incoming call for demo purposes delay(2000) } + + // Register the call with the Telecom stack telecomRepository.registerCall(name, uri, incoming) + + if (!incoming) { + // If doing an outgoing call, fake the other end picks it up for demo purposes. + delay(2000) + (telecomRepository.currentCall.value as? TelecomCall.Registered)?.processAction( + TelecomCallAction.Activate, + ) + } } } + /** + * Update our calling service based on the call state. Here is where you would update the + * connection socket, the notification, etc... + */ private fun updateServiceState(call: TelecomCall) { // Update the call notification notificationManager.updateCallNotification(call) @@ -145,7 +160,7 @@ class TelecomCallService : Service() { is TelecomCall.None -> audioLoopSource.stopAudioLoop() is TelecomCall.Registered -> { - // Update the call audio. + // Update the call state. // For this sample it means start/stop the audio loop if (call.isActive && !call.isOnHold && !call.isMuted && hasMicPermission()) { audioLoopSource.startAudioLoop() From 3e7f6a6d381b8e981d60819902a0293b4419fd14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Wed, 19 Jul 2023 11:47:17 +0200 Subject: [PATCH 23/41] Implement telecom callbacks Change-Id: Iaf07c8c41b3d6c6d3781eba6daf94be57a6e10ec --- .../com/example/platform/app/MainActivity.kt | 2 +- .../connectivity/audio/AudioSample.kt | 1 + .../audio/datasource/AudioLoopSource.kt | 6 +-- .../telecom/call/TelecomCallService.kt | 14 +++---- .../telecom/model/TelecomCallRepository.kt | 38 +++++++++++++------ 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/example/platform/app/MainActivity.kt b/app/src/main/java/com/example/platform/app/MainActivity.kt index 7ba15605..a84b56d1 100644 --- a/app/src/main/java/com/example/platform/app/MainActivity.kt +++ b/app/src/main/java/com/example/platform/app/MainActivity.kt @@ -46,4 +46,4 @@ class MainApp : Application(), ImageLoaderFactory { * Entry point for the platform samples catalog using the [CatalogActivity]. */ @AndroidEntryPoint -class MainActivity : CatalogActivity() \ No newline at end of file +class MainActivity : CatalogActivity() diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt index 9aae69d9..0dc9669a 100644 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt @@ -121,6 +121,7 @@ private fun AudioSampleScreen(viewModel: AudioDeviceViewModel) { } +@RequiresApi(Build.VERSION_CODES.S) @Composable private fun AvailableDevicesList( audioDeviceWidgetUiState: AudioDeviceViewModel.AudioDeviceListUiState, diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt index 8f4bc40c..15439fc3 100644 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt @@ -25,7 +25,6 @@ import android.media.AudioRecord import android.media.AudioTrack import android.media.MediaRecorder import android.os.Build -import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -41,7 +40,7 @@ import kotlinx.coroutines.launch */ class AudioLoopSource { - //Scope used for getting buffer from Audio recorder to audio track + // Scope used for getting buffer from Audio recorder to audio track private val coroutineScope = CoroutineScope(Dispatchers.IO) private var job: Job? = null val isRecording = MutableStateFlow(false) @@ -98,8 +97,6 @@ class AudioLoopSource { */ @SuppressLint("MissingPermission") fun startAudioLoop(): Boolean { - Log.d("MPB", "startAudioLoop") - if (audioSampler == null) { audioSampler = AudioRecord( MediaRecorder.AudioSource.VOICE_COMMUNICATION, @@ -143,7 +140,6 @@ class AudioLoopSource { * Stops current job and releases microphone and audio devices */ fun stopAudioLoop() { - Log.d("MPB", "stopAudioLoop") job?.cancel("Stop Recording", null) isRecording.update { false } if (audioSampler?.state == AudioRecord.STATE_INITIALIZED) { diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt index 3635c0c3..e110ed71 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt @@ -102,12 +102,7 @@ class TelecomCallService : Service() { when (intent.action) { ACTION_INCOMING_CALL -> registerCall(intent = intent, incoming = true) ACTION_OUTGOING_CALL -> registerCall(intent = intent, incoming = false) - ACTION_UPDATE_CALL -> { - val call = telecomRepository.currentCall.value - if (call is TelecomCall.Registered) { - updateServiceState(call) - } - } + ACTION_UPDATE_CALL -> updateServiceState(telecomRepository.currentCall.value) else -> throw IllegalArgumentException("Unknown action") } @@ -157,7 +152,10 @@ class TelecomCallService : Service() { notificationManager.updateCallNotification(call) when (call) { - is TelecomCall.None -> audioLoopSource.stopAudioLoop() + is TelecomCall.None -> { + // Stop any call tasks, in this demo we stop the audio loop + audioLoopSource.stopAudioLoop() + } is TelecomCall.Registered -> { // Update the call state. @@ -170,7 +168,7 @@ class TelecomCallService : Service() { } is TelecomCall.Unregistered -> { - // Stop the service and clean resources + // Stop service and clean resources stopSelf() } } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt index 9d5f59f3..ac195976 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -113,22 +113,42 @@ class TelecomCallRepository(private val callsManager: CallsManager) { // TODO this should eventually be moved inside the addCall method b/290562928 setCallback( // Register the callback to be notified about other call actions - // from other services or devices + // from other services or devices (e.g Auto, watch) + // In our case we will update the call status based on the callback action and + // return true to let the Telecom SDK continue the action. object : CallControlCallback { override suspend fun onAnswer(callType: Int): Boolean { - TODO("Not yet implemented") + updateCurrentCall { + copy(isActive = true, isOnHold = false) + } + return true } override suspend fun onDisconnect(disconnectCause: DisconnectCause): Boolean { - TODO("Not yet implemented") + updateCurrentCall { + TelecomCall.Unregistered(id, callAttributes, disconnectCause) + } + return true } override suspend fun onSetActive(): Boolean { - TODO("Not yet implemented") + updateCurrentCall { + copy( + isActive = true, + isOnHold = false, + ) + } + return true } override suspend fun onSetInactive(): Boolean { - TODO("Not yet implemented") + updateCurrentCall { + copy( + isActive = false, + isOnHold = true, + ) + } + return true } }, ) @@ -139,13 +159,7 @@ class TelecomCallRepository(private val callsManager: CallsManager) { processCallActions(actionSource.consumeAsFlow()) } finally { // TODO this should wrap addCall once b/291604411 is fixed - updateCurrentCall { - TelecomCall.Unregistered( - id = id, - callAttributes = callAttributes, - disconnectCause = DisconnectCause(DisconnectCause.CANCELED), - ) - } + _currentCall.value = TelecomCall.None } } From 5c42eb9c4aded455aa3661ddba3ffca97b8e0927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Thu, 20 Jul 2023 10:37:28 +0200 Subject: [PATCH 24/41] Add @Suppress("DSL_SCOPE_VIOLATION") to telecom gradle Change-Id: I51756bb769437f213b7dd2a23c837d8b47421f86 --- samples/connectivity/telecom/build.gradle.kts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/samples/connectivity/telecom/build.gradle.kts b/samples/connectivity/telecom/build.gradle.kts index 5a63b7a9..3da1e420 100644 --- a/samples/connectivity/telecom/build.gradle.kts +++ b/samples/connectivity/telecom/build.gradle.kts @@ -15,7 +15,7 @@ * limitations under the License. */ - +@Suppress("DSL_SCOPE_VIOLATION") plugins { id("com.example.platform.sample") alias(libs.plugins.kotlin.android) @@ -28,5 +28,4 @@ android { dependencies { implementation("androidx.core:core-telecom:1.0.0-SNAPSHOT") implementation(project(mapOf("path" to ":samples:connectivity:audio"))) - implementation(project(mapOf("path" to ":samples:connectivity:callnotification"))) } From e4cd36bb481092cb1b09519be54a5e8a5867c01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Thu, 20 Jul 2023 11:59:25 +0200 Subject: [PATCH 25/41] Clean up audio loop - Move everything in the same scope - When the scope is close the resources are cleaned Change-Id: Ia6b46e048c7308acafef8f2900c1d1b192664261 --- .../connectivity/audio/AudioSample.kt | 15 +- .../audio/datasource/AudioLoopSource.kt | 149 +++++++----------- .../audio/viewmodel/AudioDeviceViewModel.kt | 39 +++-- .../telecom/call/TelecomCallService.kt | 16 +- 4 files changed, 113 insertions(+), 106 deletions(-) diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt index 0dc9669a..2c9f7c43 100644 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioSample.kt @@ -20,8 +20,10 @@ import android.Manifest import android.media.AudioDeviceInfo import android.media.AudioManager import android.os.Build +import android.util.Log import android.widget.Toast import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -49,6 +51,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.getSystemService +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel import com.example.platform.base.PermissionBox import com.example.platform.connectivity.audio.datasource.PlatformAudioSource import com.example.platform.connectivity.audio.viewmodel.AudioDeviceUI @@ -63,16 +68,22 @@ import com.google.android.catalog.framework.annotations.Sample documentation = "https://developer.android.com/guide/topics/media-apps/media-apps-overview", ) @RequiresApi(Build.VERSION_CODES.S) +@RequiresPermission(Manifest.permission.RECORD_AUDIO) @Composable fun AudioSample() { val context = LocalContext.current val audioManager = context.getSystemService()!! - val viewModel = AudioDeviceViewModel(PlatformAudioSource(audioManager)) + val viewModel: AudioDeviceViewModel = viewModel(factory = object: ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(AudioDeviceViewModel(PlatformAudioSource(audioManager))) as T + } + }) PermissionBox(permission = Manifest.permission.RECORD_AUDIO) { AudioSampleScreen(viewModel) } } +@RequiresPermission(Manifest.permission.RECORD_AUDIO) @RequiresApi(Build.VERSION_CODES.S) @Composable private fun AudioSampleScreen(viewModel: AudioDeviceViewModel) { @@ -82,6 +93,8 @@ private fun AudioSampleScreen(viewModel: AudioDeviceViewModel) { val uiStateErrorMessage by viewModel.errorUiState.collectAsState() val uiStateRecording by viewModel.isRecording.collectAsState() + Log.d("MPB", "AudioSampleScreen: $uiStateRecording") + if (uiStateErrorMessage != null) { LaunchedEffect(uiStateErrorMessage) { Toast.makeText(context, uiStateErrorMessage, Toast.LENGTH_LONG).show() diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt index 15439fc3..74713946 100644 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt @@ -16,7 +16,7 @@ package com.example.platform.connectivity.audio.datasource -import android.annotation.SuppressLint +import android.Manifest import android.media.AudioAttributes import android.media.AudioDeviceInfo import android.media.AudioFormat @@ -25,44 +25,42 @@ import android.media.AudioRecord import android.media.AudioTrack import android.media.MediaRecorder import android.os.Build -import kotlinx.coroutines.CoroutineScope +import androidx.annotation.RequiresPermission import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Audio Looping, uses AudioRecord and Audio track to loop audio from microphone back to output device * Used for testing microphones and speakers */ -class AudioLoopSource { +object AudioLoopSource { - // Scope used for getting buffer from Audio recorder to audio track - private val coroutineScope = CoroutineScope(Dispatchers.IO) - private var job: Job? = null - val isRecording = MutableStateFlow(false) + private const val SAMPLE_RATE = 48000 - companion object { - private const val sampleRate = 48000 - - private val bufferSize = AudioRecord.getMinBufferSize( - sampleRate, + /** + * Opens the mic and loops it back to the selected active audio device. When the scope is closed + * the mic and audio will be closed + * + * @throws IllegalStateException if AudioRecord couldn't be initialized + */ + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + suspend fun openAudioLoop(preferredDevice: Flow = emptyFlow()) { + // Init the recorder and audio + val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, ) - private val audioTrackBufferSize = AudioTrack.getMinBufferSize( - sampleRate, + val audioTrackBufferSize = AudioTrack.getMinBufferSize( + SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, ) - - var audioSampler: AudioRecord? = null - - //Audio track for audio playback - private val audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val audioTrack = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { AudioTrack.Builder() .setAudioAttributes( AudioAttributes.Builder() @@ -73,90 +71,65 @@ class AudioLoopSource { .setAudioFormat( AudioFormat.Builder() .setEncoding(AudioFormat.ENCODING_PCM_16BIT) - .setSampleRate(sampleRate) + .setSampleRate(SAMPLE_RATE) .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) .build(), ) .setBufferSizeInBytes(audioTrackBufferSize) .build() } else { + @Suppress("DEPRECATION") AudioTrack( AudioManager.STREAM_VOICE_CALL, - sampleRate, + SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT, audioTrackBufferSize, AudioTrack.MODE_STREAM, ) } + val audioSampler = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize, + ) + audioTrack.playbackRate = SAMPLE_RATE + + try { + // Launch in a new context the loop + withContext(Dispatchers.IO) { + // Collect changes of preferred device to loop the audio + launch { + preferredDevice.collect { device -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + audioTrack.preferredDevice = device + audioSampler.preferredDevice = device + } else { + //Not required AudioManger will deal with routing in the PlatformAudioSource class + } + } + } - } - - /** - * Gets buffer from Audio Recorder and loops back to the audio track - */ - @SuppressLint("MissingPermission") - fun startAudioLoop(): Boolean { - if (audioSampler == null) { - audioSampler = AudioRecord( - MediaRecorder.AudioSource.VOICE_COMMUNICATION, - sampleRate, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, - bufferSize, - ) - } - - if (audioSampler?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { - return false - } - - audioTrack.playbackRate = sampleRate - - job = coroutineScope.launch { - if (audioSampler?.state == AudioRecord.STATE_INITIALIZED) { - audioSampler?.startRecording() - } - - val data = ByteArray(bufferSize) - audioTrack.play() - - isRecording.update { true } - - while (isActive) { + check(audioSampler.state == AudioRecord.STATE_INITIALIZED) { + "Audio recorder was not properly initialized" + } + audioSampler.startRecording() - val bytesRead = audioSampler!!.read(data, 0, bufferSize) + val audioData = ByteArray(bufferSize) + audioTrack.play() - if (bytesRead > 0) { - audioTrack.write(data, 0, bytesRead) + while (isActive) { + val bytesRead = audioSampler.read(audioData, 0, bufferSize) + if (bytesRead > 0) { + audioTrack.write(audioData, 0, bytesRead) + } } } - } - - return true - } - - /** - * Stops current job and releases microphone and audio devices - */ - fun stopAudioLoop() { - job?.cancel("Stop Recording", null) - isRecording.update { false } - if (audioSampler?.state == AudioRecord.STATE_INITIALIZED) { - audioSampler?.stop() - } - audioTrack.stop() - } - - /** - * Set the audio device to record and playback with - */ - fun setPreferredDevice(audioDeviceInfo: AudioDeviceInfo) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - audioTrack.preferredDevice = audioDeviceInfo - audioSampler?.preferredDevice = audioDeviceInfo - } else { - //Not required AudioManger will deal with routing in the PlatformAudioSource class + } finally { + audioTrack.stop() + audioSampler.stop() } } } diff --git a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/viewmodel/AudioDeviceViewModel.kt b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/viewmodel/AudioDeviceViewModel.kt index 88079978..efab0286 100644 --- a/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/viewmodel/AudioDeviceViewModel.kt +++ b/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/viewmodel/AudioDeviceViewModel.kt @@ -16,13 +16,18 @@ package com.example.platform.connectivity.audio.viewmodel +import android.Manifest import android.media.AudioDeviceInfo import android.os.Build import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.platform.connectivity.audio.datasource.AudioLoopSource import com.example.platform.connectivity.audio.datasource.PlatformAudioSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -40,8 +45,12 @@ import kotlinx.coroutines.launch @RequiresApi(Build.VERSION_CODES.S) class AudioDeviceViewModel(private val platformAudioSource: PlatformAudioSource) : ViewModel() { - private val audioLoopSource = AudioLoopSource() - val isRecording: StateFlow = audioLoopSource.isRecording.asStateFlow() + private val recordingState = MutableStateFlow(null) + private val preferredDevice = Channel() + + val isRecording: StateFlow = recordingState.map { + it?.isActive == true + }.stateIn(viewModelScope, SharingStarted.Lazily, false) /** * Get active audio device and pass to UI @@ -105,7 +114,7 @@ class AudioDeviceViewModel(private val platformAudioSource: PlatformAudioSource) if (!success) { _errorUiState.update { "Error Connecting to Device" } } else { - audioLoopSource.setPreferredDevice(audioDeviceInfo) + preferredDevice.trySend(audioDeviceInfo) } } } @@ -114,21 +123,27 @@ class AudioDeviceViewModel(private val platformAudioSource: PlatformAudioSource) _errorUiState.update { null } } + @RequiresPermission(Manifest.permission.RECORD_AUDIO) fun onToggleAudioRecording() { - if (!audioLoopSource.isRecording.value) { - if (!audioLoopSource.startAudioLoop()) { - _errorUiState.update { "Error Starting Recording" } + if (recordingState.value?.isActive != true) { + recordingState.value = viewModelScope.launch { + try { + AudioLoopSource.openAudioLoop() + } catch (e: Exception) { + if (e !is CancellationException) { + _errorUiState.update { "Error While Recording" } + } + } finally { + recordingState.value?.cancel() + recordingState.value = null + } } } else { - audioLoopSource.stopAudioLoop() + recordingState.value?.cancel() + recordingState.value = null } } - override fun onCleared() { - super.onCleared() - audioLoopSource.stopAudioLoop() - } - sealed interface AudioDeviceListUiState { object Loading : AudioDeviceListUiState data class Success(val audioDevices: List) : AudioDeviceListUiState diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt index e110ed71..4b1b637f 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt @@ -17,6 +17,7 @@ package com.example.platform.connectivity.telecom.call import android.Manifest +import android.annotation.SuppressLint import android.app.Service import android.content.Intent import android.net.Uri @@ -29,6 +30,7 @@ import com.example.platform.connectivity.telecom.model.TelecomCall import com.example.platform.connectivity.telecom.model.TelecomCallAction import com.example.platform.connectivity.telecom.model.TelecomCallRepository import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay @@ -65,8 +67,8 @@ class TelecomCallService : Service() { private lateinit var notificationManager: TelecomCallNotificationManager private lateinit var telecomRepository: TelecomCallRepository - private val audioLoopSource = AudioLoopSource() private val scope: CoroutineScope = CoroutineScope(SupervisorJob()) + private var audioJob: Job? = null override fun onCreate() { super.onCreate() @@ -90,7 +92,6 @@ class TelecomCallService : Service() { super.onDestroy() // Remove notification and clean resources scope.cancel() - audioLoopSource.stopAudioLoop() notificationManager.updateCallNotification(TelecomCall.None) } @@ -147,6 +148,7 @@ class TelecomCallService : Service() { * Update our calling service based on the call state. Here is where you would update the * connection socket, the notification, etc... */ + @SuppressLint("MissingPermission") private fun updateServiceState(call: TelecomCall) { // Update the call notification notificationManager.updateCallNotification(call) @@ -154,16 +156,20 @@ class TelecomCallService : Service() { when (call) { is TelecomCall.None -> { // Stop any call tasks, in this demo we stop the audio loop - audioLoopSource.stopAudioLoop() + audioJob?.cancel() } is TelecomCall.Registered -> { // Update the call state. // For this sample it means start/stop the audio loop if (call.isActive && !call.isOnHold && !call.isMuted && hasMicPermission()) { - audioLoopSource.startAudioLoop() + if (audioJob == null || audioJob?.isActive == false) { + audioJob = scope.launch { + AudioLoopSource.openAudioLoop() + } + } } else { - audioLoopSource.stopAudioLoop() + audioJob?.cancel() } } From 2fa25b3935fa45de17451bbcf03952883a6209b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Tue, 25 Jul 2023 15:10:09 +0200 Subject: [PATCH 26/41] Add new CompanionDeviceManagerSample This sample showcases how to use CDM to associate a BLE device and connect to it Change-Id: I6b94db41c1fb8b789ea8443d958748e85ad61d8d --- samples/README.md | 2 + .../bluetooth/ble/GATTServerSample.kt | 2 +- .../bluetooth/companion/README.md | 21 ++ .../bluetooth/companion/build.gradle.kts | 28 ++ .../companion/src/main/AndroidManifest.xml | 25 ++ .../ble/CompanionDeviceManagerSample.kt | 304 ++++++++++++++++++ 6 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 samples/connectivity/bluetooth/companion/README.md create mode 100644 samples/connectivity/bluetooth/companion/build.gradle.kts create mode 100644 samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml create mode 100644 samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt diff --git a/samples/README.md b/samples/README.md index 805f0d37..4252102a 100644 --- a/samples/README.md +++ b/samples/README.md @@ -10,6 +10,8 @@ Sample demonstrating how to make incoming call notifications and in call notific Demonstrates displaying processed pixel data directly from the camera sensor - [Color Contrast](accessibility/src/main/java/com/example/platform/accessibility/ColorContrast.kt): This sample demonstrates the importance of proper color contrast and how to +- [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt): +This samples shows how to use the CDM to pair and connect with BLE devices - [Connect to a GATT server](connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt): Shows how to connect to a GATT server hosted by the BLE device and perform simple operations - [ConstraintLayout - 1. Centering Views](user-interface/constraintlayout/src/main/java/com/example/platform/ui/constraintlayout/ConstraintLayout.kt): diff --git a/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt b/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt index 80340239..fba3f661 100644 --- a/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt +++ b/samples/connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/GATTServerSample.kt @@ -244,10 +244,10 @@ private fun GATTServerEffect( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) bluetoothLeAdvertiser.stopAdvertising(advertiseCallback) - gattServer?.close() manager.getConnectedDevices(BluetoothProfile.GATT_SERVER)?.forEach { gattServer?.cancelConnection(it) } + gattServer?.close() } } } diff --git a/samples/connectivity/bluetooth/companion/README.md b/samples/connectivity/bluetooth/companion/README.md new file mode 100644 index 00000000..81556fff --- /dev/null +++ b/samples/connectivity/bluetooth/companion/README.md @@ -0,0 +1,21 @@ +# Companion Device Manager Sample + +// TODO + +## License + +``` +Copyright 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/samples/connectivity/bluetooth/companion/build.gradle.kts b/samples/connectivity/bluetooth/companion/build.gradle.kts new file mode 100644 index 00000000..0f575217 --- /dev/null +++ b/samples/connectivity/bluetooth/companion/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.example.platform.sample") +} + + +android { + namespace = "com.example.platform.connectivity.bluetooth.companion" +} + +dependencies { + implementation(project(":samples:connectivity:bluetooth:ble")) +} diff --git a/samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml b/samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml new file mode 100644 index 00000000..29057767 --- /dev/null +++ b/samples/connectivity/bluetooth/companion/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt new file mode 100644 index 00000000..5d927d56 --- /dev/null +++ b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt @@ -0,0 +1,304 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.bluetooth.ble + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.companion.AssociationInfo +import android.companion.AssociationRequest +import android.companion.BluetoothLeDeviceFilter +import android.companion.CompanionDeviceManager +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.ParcelUuid +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.getSystemService +import com.example.platform.base.PermissionBox +import com.google.android.catalog.framework.annotations.Sample +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import java.util.concurrent.Executor + +@Sample( + name = "Companion Device Manager Sample", + description = "This samples shows how to use the CDM to pair and connect with BLE devices", + documentation = "https://developer.android.com/guide/topics/connectivity/companion-device-pairing", + tags = ["bluetooth"], +) +@SuppressLint("InlinedApi", "MissingPermission") +@RequiresApi(Build.VERSION_CODES.O) +@Composable +fun CompanionDeviceManagerSample() { + val context = LocalContext.current + val deviceManager = context.getSystemService() + val adapter = context.getSystemService()?.adapter + var selectedDevice by remember { + mutableStateOf(null) + } + if (deviceManager == null || adapter == null) { + Text(text = "No Companion device manager found. The device does not support it.") + } else { + if (selectedDevice == null) { + CDMScreen(deviceManager) { + selectedDevice = it.device ?: adapter.getRemoteDevice(it.name) + } + } else { + PermissionBox(permission = Manifest.permission.BLUETOOTH_CONNECT) { + ConnectDeviceScreen(device = selectedDevice!!) { + selectedDevice = null + } + } + } + } +} + +data class AssociatedDevice( + val id: Int, + val address: String, + val name: String, + val device: BluetoothDevice?, +) + +@OptIn(ExperimentalAnimationApi::class) +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun CDMScreen( + deviceManager: CompanionDeviceManager, + onConnect: (AssociatedDevice) -> Unit, +) { + val scope = rememberCoroutineScope() + var associatedDevice by remember { + // If we already associated the device no need to do it again. + mutableStateOf(getAssociatedDevice(deviceManager)) + } + var errorMessage by remember(associatedDevice) { + mutableStateOf("") + } + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartIntentSenderForResult(), + ) { + when (it.resultCode) { + CompanionDeviceManager.RESULT_OK -> { + associatedDevice = it.data?.getAssociationResult() + } + + CompanionDeviceManager.RESULT_CANCELED -> { + errorMessage = "The request was canceled" + } + + CompanionDeviceManager.RESULT_INTERNAL_ERROR -> { + errorMessage = "Internal error happened" + } + + CompanionDeviceManager.RESULT_DISCOVERY_TIMEOUT -> { + errorMessage = "No device matching the given filter were found" + } + + CompanionDeviceManager.RESULT_USER_REJECTED -> { + errorMessage = "The user explicitly declined the request" + } + + else -> { + errorMessage = "Unknown error" + } + } + } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + AnimatedContent(targetState = associatedDevice, label = "") { target -> + if (target != null) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = "ID: ${target.id}") + Text(text = "MAC: ${target.address}") + Text(text = "Name: ${target.name}") + Button( + onClick = { + onConnect(target) + }, + ) { + Text(text = "Connect") + } + Button( + onClick = { + scope.launch { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + deviceManager.disassociate(target.id) + } else { + @Suppress("DEPRECATION") + deviceManager.disassociate(target.address) + } + associatedDevice = null + } + }, + ) { + Text(text = "Disassociate") + } + } + } else { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { + scope.launch { + val intentSender = requestDeviceAssociation(deviceManager) + launcher.launch(IntentSenderRequest.Builder(intentSender).build()) + } + }, + ) { + Text(text = "Find & Associate device") + } + if (errorMessage.isNotBlank()) { + Text(text = errorMessage, color = MaterialTheme.colorScheme.error) + } + } + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun getAssociatedDevice(deviceManager: CompanionDeviceManager): AssociatedDevice? { + val associatedDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + deviceManager.myAssociations.lastOrNull()?.toAssociatedDevice() + } else { + // Before Android 34 we can only get the MAC. We could use the BT adapter to find the + // device, but to use CDM we only need the MAC. + @Suppress("DEPRECATION") + deviceManager.associations.lastOrNull()?.run { + AssociatedDevice( + id = -1, + address = this, + name = "", + device = null, + ) + } + } + return associatedDevice +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun Intent.getAssociationResult(): AssociatedDevice? { + var result: AssociatedDevice? = null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result = getParcelableExtra( + CompanionDeviceManager.EXTRA_ASSOCIATION, + AssociationInfo::class.java, + )?.toAssociatedDevice() + } else { + // Below Android 33 the result returns either a BLE ScanResult, a + // Classic BluetoothDevice or a Wifi ScanResult + // In our case we are looking for our BLE GATT server so we can cast directly + // to the BLE ScanResult + @Suppress("DEPRECATION") + val scanResult = getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE) + if (scanResult != null) { + result = AssociatedDevice( + id = scanResult.advertisingSid, + address = scanResult.device.address ?: "N/A", + name = scanResult.scanRecord?.deviceName ?: "N/A", + device = scanResult.device, + ) + } + } + return result +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private fun AssociationInfo.toAssociatedDevice() = AssociatedDevice( + id = id, + address = deviceMacAddress?.toOuiString() ?: "N/A", + name = displayName?.ifBlank { "N/A" }?.toString() ?: "N/A", + device = if (Build.VERSION.SDK_INT >= 34) { + associatedDevice?.bleDevice?.device + } else { + null + }, +) + +@RequiresApi(Build.VERSION_CODES.O) +suspend fun requestDeviceAssociation(deviceManager: CompanionDeviceManager): IntentSender { + // Match only Bluetooth devices whose service UUID matches this pattern. + // For this demo we will match our GATTServerSample + val scanFilter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build() + val deviceFilter = BluetoothLeDeviceFilter.Builder() + .setScanFilter(scanFilter) + .build() + + val pairingRequest: AssociationRequest = AssociationRequest.Builder() + // Find only devices that match this request filter. + .addDeviceFilter(deviceFilter) + // Stop scanning as soon as one device matching the filter is found. + .setSingleDevice(true) + .build() + + val result = CompletableDeferred() + + val callback = object : CompanionDeviceManager.Callback() { + override fun onAssociationPending(intentSender: IntentSender) { + result.complete(intentSender) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onDeviceFound(intentSender: IntentSender) { + result.complete(intentSender) + } + + override fun onAssociationCreated(associationInfo: AssociationInfo) { + // This callback was added in API 33 but the result is also send in the activity result. + // For handling backwards compatibility we can just have all the logic there instead + } + + override fun onFailure(errorMessage: CharSequence?) { + result.completeExceptionally(IllegalStateException(errorMessage?.toString().orEmpty())) + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val executor = Executor { it.run() } + deviceManager.associate(pairingRequest, executor, callback) + } else { + deviceManager.associate(pairingRequest, callback, null) + } + return result.await() +} From 25cb3f2927a3a939a28023060101bd3868b31417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Mon, 7 Aug 2023 15:38:57 +0200 Subject: [PATCH 27/41] Add TelecomSampleTest This test check that the minimal features for ongoing calls work. Change-Id: Idc5af78975e359327bdb4551060dcc0f092a054c --- app/build.gradle.kts | 2 + samples/connectivity/telecom/build.gradle.kts | 12 ++ .../connectivity/telecom/TelecomSampleTest.kt | 126 ++++++++++++++++++ .../connectivity/telecom/TelecomCallSample.kt | 16 ++- .../telecom/call/TelecomCallActivity.kt | 2 + .../telecom/call/TelecomCallScreen.kt | 2 +- 6 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fee177a2..69a8b369 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,6 +94,8 @@ dependencies { kaptAndroidTest(libs.hilt.compiler) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.androidx.navigation.testing) + androidTestImplementation(libs.compose.ui.test.manifest) + debugImplementation(libs.compose.ui.test.manifest) androidTestImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.espresso.core) diff --git a/samples/connectivity/telecom/build.gradle.kts b/samples/connectivity/telecom/build.gradle.kts index 3da1e420..72325853 100644 --- a/samples/connectivity/telecom/build.gradle.kts +++ b/samples/connectivity/telecom/build.gradle.kts @@ -28,4 +28,16 @@ android { dependencies { implementation("androidx.core:core-telecom:1.0.0-SNAPSHOT") implementation(project(mapOf("path" to ":samples:connectivity:audio"))) + + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.androidx.navigation.testing) + androidTestImplementation(libs.compose.ui.test.manifest) + debugImplementation(libs.compose.ui.test.manifest) + androidTestImplementation(libs.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.hilt.testing) + androidTestImplementation(libs.junit4) } diff --git a/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt b/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt new file mode 100644 index 00000000..063be1f9 --- /dev/null +++ b/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isOff +import androidx.compose.ui.test.isOn +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.rule.GrantPermissionRule +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * Tests all the samples are present and open. + * + * Note: consider changing the test to use the TestNavController to control navigation instead + */ +class TelecomSampleTest { + + /** + * Use the primary activity to initialize the app normally. + */ + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + private val permissionArray = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS) + } else { + listOf(Manifest.permission.RECORD_AUDIO) + } + + /** + * Avoids showing permission dialog when running certain samples + */ + @get:Rule(order = 2) + val grantPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant(*permissionArray.toTypedArray()) + + @Before + fun setUp() { + composeTestRule.setContent { + TelecomCallSample() + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun testOngoingCall() { + composeTestRule.onNodeWithText("Make fake call").performClick() + composeTestRule.apply { + val isTablet = activity.isDeviceTablet() + Log.d("TelecomSampleTest", "Testing on a tablet? $isTablet") + + // Wait till the call is connected + waitUntilExactlyOneExists(hasContentDescription("Connected"), 5000) + onNode(hasText("Bob")).assertIsDisplayed() + + // Check Toggle between speaker and earphone (except for tablets) + val speaker = "Toggle speaker" + onNodeWithContentDescription(speaker).apply { + assertIsEnabled() + assert( + if (isTablet) { + isOn() + } else { + isOff() + }, + ) + if (!isTablet) { + performClick() + } + } + if (!isTablet) { + waitUntilExactlyOneExists(hasContentDescription(speaker) and isOn(), 5000) + } + + val onHold = "Pause or resume call" + onNodeWithContentDescription(onHold).apply { + assertIsEnabled() + assert(isOff()) + performClick() + } + waitUntilExactlyOneExists(hasContentDescription(onHold) and isOn(), 5000) + + // Disconnect call and check + onNodeWithContentDescription("Disconnect call").performClick() + waitUntil { + onAllNodesWithText("Call ended").fetchSemanticsNodes().isNotEmpty() + } + } + } + + private fun Activity.isDeviceTablet() = + !packageManager.hasSystemFeature(PackageManager.FEATURE_SENSOR_HINGE_ANGLE) && + resources.configuration.smallestScreenWidthDp >= 600 +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt index 88cd42c0..d53fb444 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -86,6 +87,16 @@ private fun TelecomCallOptions() { val call by repository.currentCall.collectAsState() val hasOngoingCall = call is TelecomCall.Registered + if (hasOngoingCall) { + LaunchedEffect(Unit) { + context.startActivity( + Intent(context, TelecomCallActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + } + } + Column( modifier = Modifier .fillMaxSize() @@ -120,11 +131,6 @@ private fun TelecomCallOptions() { name = "Bob", uri = Uri.parse("tel:54321"), ) - context.startActivity( - Intent(context, TelecomCallActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - }, - ) }, ) { Text(text = "Make fake call") diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt index ebb1dec6..0da07851 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt @@ -20,6 +20,7 @@ import android.app.KeyguardManager import android.content.Intent import android.os.Build import android.os.Bundle +import android.util.Log import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -60,6 +61,7 @@ class TelecomCallActivity : ComponentActivity() { TelecomCallScreen(repository) { // If we receive that the called finished, finish the activity finishAndRemoveTask() + Log.d("TelecomCallActivity", "Call finished. Finishing activity") } } } diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt index d05a0378..5ba1e757 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt @@ -231,7 +231,7 @@ private fun OngoingCallActions( ) { Icon( imageVector = Icons.Rounded.Call, - contentDescription = null, + contentDescription = "Disconnect call", modifier = Modifier.rotate(90f), ) } From 25ee6937290ee05cceb4295b9d2bd609c27f5cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Tue, 8 Aug 2023 14:31:03 +0200 Subject: [PATCH 28/41] Update telecom sdk build Change-Id: Ibd46b696615558e8deafabf522c6c5d3f14f9478 --- samples/README.md | 4 ++++ settings.gradle.kts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/samples/README.md b/samples/README.md index 33b287ef..1c36c808 100644 --- a/samples/README.md +++ b/samples/README.md @@ -12,6 +12,8 @@ Demonstrates displaying processed pixel data directly from the camera sensor This sample demonstrates the importance of proper color contrast and how to - [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt): This samples shows how to use the CDM to pair and connect with BLE devices +- [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt): +This samples shows how to use the CDM to pair and connect with BLE devices - [Connect to a GATT server](connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt): Shows how to connect to a GATT server hosted by the BLE device and perform simple operations - [ConstraintLayout - 1. Centering Views](user-interface/constraintlayout/src/main/java/com/example/platform/ui/constraintlayout/ConstraintLayout.kt): @@ -90,6 +92,8 @@ Check and request storage permissions Shows the recommended flow to request single runtime permissions - [Speakable Text](accessibility/src/main/java/com/example/platform/accessibility/SpeakableText.kt): The sample demonstrates the importance of having proper labels for +- [Telecom Call Sample](connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt): +A sample showcasing how to handle calls with the Jetpack Telecom API - [TextSpan](user-interface/text/src/main/java/com/example/platform/ui/text/TextSpan.kt): buildSpannedString is useful for quickly building a rich text. - [UltraHDR Image Capture](camera/camera2/src/main/java/com/example/platform/camera/imagecapture/Camera2UltraHDRCapture.kt): diff --git a/settings.gradle.kts b/settings.gradle.kts index 1ed276ad..9f5b5592 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,7 +32,7 @@ dependencyResolutionManagement { mavenCentral() // Using SNAPSHOTS for Telecom SDK. This should be removed once telecom SDK is stable - maven { url = uri("https://androidx.dev/snapshots/builds/10506499/artifacts/repository") } + maven { url = uri("https://androidx.dev/snapshots/builds/10626110/artifacts/repository") } } } From 47ff9dc64726e95bcea7c534283230058b9acc2c Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Mon, 14 Aug 2023 21:52:32 +0000 Subject: [PATCH 29/41] Remove Toogle buttons and Show all Audio Devices Remove the abiity to toggle bluetooth and speakers phone. Replace with a list of all the available audio devices. Covers user cases where users may have a watch and headphones paired --- .idea/migrations.xml | 10 ++ samples/README.md | 4 + .../telecom/call/TelecomCallScreen.kt | 126 +++++++++--------- .../telecom/model/TelecomCallAction.kt | 2 +- .../telecom/model/TelecomCallRepository.kt | 13 +- 5 files changed, 81 insertions(+), 74 deletions(-) create mode 100644 .idea/migrations.xml diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/samples/README.md b/samples/README.md index 33b287ef..07f71ff4 100644 --- a/samples/README.md +++ b/samples/README.md @@ -10,6 +10,8 @@ Sample demonstrating how to make incoming call notifications and in call notific Demonstrates displaying processed pixel data directly from the camera sensor - [Color Contrast](accessibility/src/main/java/com/example/platform/accessibility/ColorContrast.kt): This sample demonstrates the importance of proper color contrast and how to +- [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt): +This samples shows how to use the CDM to pair and connect with BLE devices - [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt): This samples shows how to use the CDM to pair and connect with BLE devices - [Connect to a GATT server](connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt): @@ -90,6 +92,8 @@ Check and request storage permissions Shows the recommended flow to request single runtime permissions - [Speakable Text](accessibility/src/main/java/com/example/platform/accessibility/SpeakableText.kt): The sample demonstrates the importance of having proper labels for +- [Telecom Call Sample](connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt): +A sample showcasing how to handle calls with the Jetpack Telecom API - [TextSpan](user-interface/text/src/main/java/com/example/platform/ui/text/TextSpan.kt): buildSpannedString is useful for quickly building a rich text. - [UltraHDR Image Capture](camera/camera2/src/main/java/com/example/platform/camera/imagecapture/Camera2UltraHDRCapture.kt): diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt index 5ba1e757..2cd19bc7 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt @@ -37,16 +37,20 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.BluetoothAudio -import androidx.compose.material.icons.rounded.BluetoothDisabled import androidx.compose.material.icons.rounded.Call +import androidx.compose.material.icons.rounded.Headphones import androidx.compose.material.icons.rounded.Mic import androidx.compose.material.icons.rounded.MicOff import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Phone import androidx.compose.material.icons.rounded.PhoneForwarded import androidx.compose.material.icons.rounded.PhonePaused -import androidx.compose.material.icons.rounded.VolumeUp +import androidx.compose.material.icons.rounded.SendToMobile +import androidx.compose.material.icons.rounded.SpeakerPhone import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -68,8 +72,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.core.telecom.CallEndpointCompat +import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_BLUETOOTH +import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_SPEAKER +import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_STREAMING +import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_WIRED_HEADSET import com.example.platform.connectivity.telecom.model.TelecomCall import com.example.platform.connectivity.telecom.model.TelecomCallAction import com.example.platform.connectivity.telecom.model.TelecomCallRepository @@ -213,7 +222,7 @@ private fun OngoingCallActions( isOnHold = isOnHold, isMuted = isMuted, endpointType = currentEndpoint?.type ?: CallEndpointCompat.TYPE_UNKNOWN, - availableTypes = endpoints.map { it.type }, + availableTypes = endpoints, onCallAction = onCallAction, onTransferCall = onTransferCall, ) @@ -302,6 +311,7 @@ private fun CallInfoCard(name: String, info: String, isActive: Boolean) { /** * Displays the call controls based on the current call attributes */ +@RequiresApi(Build.VERSION_CODES.O) @OptIn(ExperimentalPermissionsApi::class) @Composable private fun CallControls( @@ -309,7 +319,7 @@ private fun CallControls( isOnHold: Boolean, isMuted: Boolean, endpointType: @CallEndpointCompat.Companion.EndpointType Int, - availableTypes: List<@CallEndpointCompat.Companion.EndpointType Int>, + availableTypes: List, onCallAction: (TelecomCallAction) -> Unit, onTransferCall: () -> Unit, ) { @@ -319,6 +329,10 @@ private fun CallControls( mutableStateOf(false) } + var showEndPoints by remember { + mutableStateOf(false) + } + Row( modifier = Modifier .fillMaxWidth() @@ -355,46 +369,24 @@ private fun CallControls( ) } } - IconToggleButton( - checked = endpointType == CallEndpointCompat.TYPE_SPEAKER, - enabled = isLocalCall, - onCheckedChange = { selected -> - val type = if (selected) { - CallEndpointCompat.TYPE_SPEAKER - } else { - // Switch to either wired headset or earpiece - availableTypes.firstOrNull { it == CallEndpointCompat.TYPE_WIRED_HEADSET } - ?: CallEndpointCompat.TYPE_EARPIECE - } - onCallAction(TelecomCallAction.SwitchAudioType(type)) - }, - ) { - Icon(imageVector = Icons.Rounded.VolumeUp, contentDescription = "Toggle speaker") - } - if (availableTypes.contains(CallEndpointCompat.TYPE_BLUETOOTH)) { - IconToggleButton( - checked = endpointType == CallEndpointCompat.TYPE_BLUETOOTH, - enabled = isLocalCall, - onCheckedChange = { selected -> - val type = if (selected) { - CallEndpointCompat.TYPE_BLUETOOTH - } else { - // Switch to the default endpoint (as defined in TelecomCallRepo) - availableTypes.firstOrNull { it == CallEndpointCompat.TYPE_WIRED_HEADSET } - ?: CallEndpointCompat.TYPE_EARPIECE - } - onCallAction(TelecomCallAction.SwitchAudioType(type)) - }, + Box { + IconButton(onClick = { showEndPoints = !showEndPoints }) { + Icon( + EndPointVectorIcon(endpointType), + contentDescription = "Localized description", + ) + } + DropdownMenu( + expanded = showEndPoints, + onDismissRequest = { showEndPoints = false }, ) { - if (endpointType == CallEndpointCompat.TYPE_BLUETOOTH) { - Icon( - imageVector = Icons.Rounded.BluetoothAudio, - contentDescription = "Disable bluetooth", - ) - } else { - Icon( - imageVector = Icons.Rounded.BluetoothDisabled, - contentDescription = "Enable bluetooth", + availableTypes.forEach{ it -> + CallEndPointItem( + endPoint = it, + onDeviceSelected = { + onCallAction(TelecomCallAction.SwitchAudioEndpoint(it.identifier)) + showEndPoints = false + }, ) } } @@ -416,26 +408,6 @@ private fun CallControls( contentDescription = "Pause or resume call", ) } - - if (availableTypes.contains(CallEndpointCompat.TYPE_STREAMING)) { - IconToggleButton( - enabled = isActive, - checked = !isLocalCall, - onCheckedChange = { - if (it) { - onTransferCall() - } else { - // Switch back to the default audio type - onCallAction(TelecomCallAction.SwitchAudioType(CallEndpointCompat.TYPE_UNKNOWN)) - } - }, - ) { - Icon( - imageVector = Icons.Rounded.PhoneForwarded, - contentDescription = "Transfer call", - ) - } - } } // Show a rational dialog if user didn't accepted the permissions @@ -451,6 +423,34 @@ private fun CallControls( } } +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun CallEndPointItem( + endPoint: CallEndpointCompat, + onDeviceSelected: (CallEndpointCompat) -> Unit, +) { + DropdownMenuItem( + text = { Text(text = endPoint.name.toString()) }, + onClick = { onDeviceSelected(endPoint) }, + leadingIcon = { + Icon( + EndPointVectorIcon(endPoint.type), + contentDescription = null, + ) + } + ) +} +@Composable +private fun EndPointVectorIcon(type: @CallEndpointCompat.Companion.EndpointType Int): ImageVector{ + return when(type){ + TYPE_BLUETOOTH -> Icons.Rounded.BluetoothAudio + TYPE_SPEAKER -> Icons.Rounded.SpeakerPhone + TYPE_STREAMING -> Icons.Rounded.SendToMobile + TYPE_WIRED_HEADSET -> Icons.Rounded.Headphones + else -> Icons.Rounded.Phone + } +} + @RequiresApi(Build.VERSION_CODES.O) @Composable @OptIn(ExperimentalMaterial3Api::class) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt index 67654f0e..4199c4c3 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt @@ -45,7 +45,7 @@ sealed interface TelecomCallAction : Parcelable { data class ToggleMute(val isMute: Boolean) : TelecomCallAction @Parcelize - data class SwitchAudioType(val type: Int) : TelecomCallAction + data class SwitchAudioEndpoint(val endpointId: ParcelUuid) : TelecomCallAction @Parcelize data class TransferCall(val endpointId: ParcelUuid) : TelecomCallAction diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt index ac195976..ada54b03 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -213,7 +213,7 @@ class TelecomCallRepository(private val callsManager: CallsManager) { doDisconnect(action) } - is TelecomCallAction.SwitchAudioType -> { + is TelecomCallAction.SwitchAudioEndpoint -> { doSwitchEndpoint(action) } @@ -271,19 +271,12 @@ class TelecomCallRepository(private val callsManager: CallsManager) { } } - private suspend fun CallControlScope.doSwitchEndpoint(action: TelecomCallAction.SwitchAudioType) { + private suspend fun CallControlScope.doSwitchEndpoint(action: TelecomCallAction.SwitchAudioEndpoint) { // TODO once availableCallEndpoints is a state flow we can just get the value val endpoints = (_currentCall.value as TelecomCall.Registered).availableCallEndpoints // Switch to the given endpoint or fallback to the best possible one. - val newEndpoint = endpoints.firstOrNull { it.type == action.type } - ?: endpoints.firstOrNull { - it.type == CallEndpointCompat.TYPE_BLUETOOTH - } ?: endpoints.firstOrNull { - it.type == CallEndpointCompat.TYPE_WIRED_HEADSET - } ?: endpoints.firstOrNull { - it.type == CallEndpointCompat.TYPE_EARPIECE - } ?: endpoints.firstOrNull() + val newEndpoint = endpoints.firstOrNull { it.identifier == action.endpointId } if (newEndpoint != null) { requestEndpointChange(newEndpoint).also { From 8543c6cedd2f6c3b2b60df89a3ec8aacfd9c5dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Wed, 16 Aug 2023 11:03:49 +0200 Subject: [PATCH 30/41] Remove transfer call leftover logic Change-Id: I3f23ac2919a64e8ab425f7c1b489dbd9098fc3b1 --- samples/README.md | 2 - .../telecom/call/TelecomCallScreen.kt | 115 ++++-------------- 2 files changed, 24 insertions(+), 93 deletions(-) diff --git a/samples/README.md b/samples/README.md index d5c6d187..1c36c808 100644 --- a/samples/README.md +++ b/samples/README.md @@ -10,8 +10,6 @@ Sample demonstrating how to make incoming call notifications and in call notific Demonstrates displaying processed pixel data directly from the camera sensor - [Color Contrast](accessibility/src/main/java/com/example/platform/accessibility/ColorContrast.kt): This sample demonstrates the importance of proper color contrast and how to -- [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt): -This samples shows how to use the CDM to pair and connect with BLE devices - [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt): This samples shows how to use the CDM to pair and connect with BLE devices - [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt): diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt index 2cd19bc7..cae75e98 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt @@ -20,10 +20,8 @@ import android.Manifest import android.os.Build import android.telecom.DisconnectCause import androidx.annotation.RequiresApi -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,11 +29,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.ArrowDropUp import androidx.compose.material.icons.rounded.BluetoothAudio import androidx.compose.material.icons.rounded.Call import androidx.compose.material.icons.rounded.Headphones @@ -43,22 +39,17 @@ import androidx.compose.material.icons.rounded.Mic import androidx.compose.material.icons.rounded.MicOff import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Phone -import androidx.compose.material.icons.rounded.PhoneForwarded import androidx.compose.material.icons.rounded.PhonePaused import androidx.compose.material.icons.rounded.SendToMobile import androidx.compose.material.icons.rounded.SpeakerPhone import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -152,12 +143,6 @@ private fun CallScreen( endpoints: List, onCallAction: (TelecomCallAction) -> Unit, ) { - var showTransferEndpoints by remember { - mutableStateOf(false) - } - val transferEndpoints = remember(endpoints) { - endpoints.filter { it.type == CallEndpointCompat.TYPE_STREAMING } - } Column( Modifier .fillMaxSize(), @@ -175,26 +160,9 @@ private fun CallScreen( currentEndpoint = currentEndpoint, endpoints = endpoints, onCallAction = onCallAction, - onTransferCall = { - showTransferEndpoints = true - }, ) } } - - // Show a picker when selecting to transfer a call - AnimatedVisibility(visible = showTransferEndpoints) { - TransferCallDialog( - transferEndpoints = transferEndpoints, - onDismissRequest = { - showTransferEndpoints = false - }, - onCallAction = { - showTransferEndpoints = false - onCallAction(it) - }, - ) - } } @RequiresApi(Build.VERSION_CODES.O) @@ -206,7 +174,6 @@ private fun OngoingCallActions( currentEndpoint: CallEndpointCompat?, endpoints: List, onCallAction: (TelecomCallAction) -> Unit, - onTransferCall: () -> Unit, ) { Column( Modifier @@ -224,7 +191,6 @@ private fun OngoingCallActions( endpointType = currentEndpoint?.type ?: CallEndpointCompat.TYPE_UNKNOWN, availableTypes = endpoints, onCallAction = onCallAction, - onTransferCall = onTransferCall, ) FloatingActionButton( onClick = { @@ -321,9 +287,7 @@ private fun CallControls( endpointType: @CallEndpointCompat.Companion.EndpointType Int, availableTypes: List, onCallAction: (TelecomCallAction) -> Unit, - onTransferCall: () -> Unit, ) { - val isLocalCall = endpointType != CallEndpointCompat.TYPE_STREAMING val micPermission = rememberPermissionState(permission = Manifest.permission.RECORD_AUDIO) var showRational by remember(micPermission.status) { mutableStateOf(false) @@ -372,21 +336,30 @@ private fun CallControls( Box { IconButton(onClick = { showEndPoints = !showEndPoints }) { Icon( - EndPointVectorIcon(endpointType), + getEndpointIcon(endpointType), + contentDescription = "Localized description", + ) + Icon( + if (showEndPoints) { + Icons.Rounded.ArrowDropUp + } else { + Icons.Rounded.ArrowDropDown + }, contentDescription = "Localized description", + modifier = Modifier.align(Alignment.TopEnd), ) } DropdownMenu( expanded = showEndPoints, onDismissRequest = { showEndPoints = false }, ) { - availableTypes.forEach{ it -> + availableTypes.forEach { it -> CallEndPointItem( endPoint = it, - onDeviceSelected = { + onDeviceSelected = { onCallAction(TelecomCallAction.SwitchAudioEndpoint(it.identifier)) showEndPoints = false - }, + }, ) } } @@ -434,60 +407,20 @@ private fun CallEndPointItem( onClick = { onDeviceSelected(endPoint) }, leadingIcon = { Icon( - EndPointVectorIcon(endPoint.type), + getEndpointIcon(endPoint.type), contentDescription = null, ) - } + }, ) } -@Composable -private fun EndPointVectorIcon(type: @CallEndpointCompat.Companion.EndpointType Int): ImageVector{ - return when(type){ - TYPE_BLUETOOTH -> Icons.Rounded.BluetoothAudio - TYPE_SPEAKER -> Icons.Rounded.SpeakerPhone - TYPE_STREAMING -> Icons.Rounded.SendToMobile - TYPE_WIRED_HEADSET -> Icons.Rounded.Headphones - else -> Icons.Rounded.Phone - } -} -@RequiresApi(Build.VERSION_CODES.O) -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun TransferCallDialog( - transferEndpoints: List, - onDismissRequest: () -> Unit, - onCallAction: (TelecomCallAction) -> Unit, -) { - AlertDialog(onDismissRequest = onDismissRequest) { - Surface( - modifier = Modifier - .wrapContentWidth() - .wrapContentHeight(), - shape = MaterialTheme.shapes.large, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - Column(Modifier.padding(16.dp)) { - Text(text = "Where to transfer the call?") - LazyColumn { - items(transferEndpoints) { - Text( - text = it.name.toString(), - modifier = Modifier - .fillMaxWidth() - .clickable { - onCallAction(TelecomCallAction.TransferCall(it.identifier)) - }, - ) - } - } - Row(horizontalArrangement = Arrangement.End) { - OutlinedButton(onClick = onDismissRequest) { - Text(text = "Dismiss") - } - } - } - } +private fun getEndpointIcon(type: @CallEndpointCompat.Companion.EndpointType Int): ImageVector { + return when (type) { + TYPE_BLUETOOTH -> Icons.Rounded.BluetoothAudio + TYPE_SPEAKER -> Icons.Rounded.SpeakerPhone + TYPE_STREAMING -> Icons.Rounded.SendToMobile + TYPE_WIRED_HEADSET -> Icons.Rounded.Headphones + else -> Icons.Rounded.Phone } } From ca1650c02756c0bf253bf8ae7ae6e2e5a9c91fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Wed, 16 Aug 2023 14:13:04 +0200 Subject: [PATCH 31/41] Fix audioloopsource path Change-Id: I98a6d0e54dc73efc4d092fc9b3f66bab801a99c0 --- .../platform/connectivity/telecom/call/TelecomCallService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt index 4b1b637f..f1a45e84 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt @@ -25,7 +25,7 @@ import android.os.Build import android.os.IBinder import androidx.annotation.RequiresApi import androidx.core.content.PermissionChecker -import com.example.platform.connectivity.audio.datasource.AudioLoopSource +import com.example.platform.connectivity.audio.AudioLoopSource import com.example.platform.connectivity.telecom.model.TelecomCall import com.example.platform.connectivity.telecom.model.TelecomCallAction import com.example.platform.connectivity.telecom.model.TelecomCallRepository From 3297fee218691cf18076119876783536eea0d694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Pint=C3=B3=20Biescas?= Date: Wed, 16 Aug 2023 14:16:21 +0200 Subject: [PATCH 32/41] Add telecom tag Change-Id: I803936cb63f8bd58b62a7dec390c51ba4efcbfc1 --- .../example/platform/connectivity/telecom/TelecomCallSample.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt index d53fb444..52aeeaf3 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt @@ -50,6 +50,7 @@ import com.google.android.catalog.framework.annotations.Sample name = "Telecom Call Sample", description = "A sample showcasing how to handle calls with the Jetpack Telecom API", documentation = "https://developer.android.com/guide/topics/connectivity/telecom", + tags = ["telecom"] ) @RequiresApi(Build.VERSION_CODES.O) @Composable From 2e5a31e5218fcd7e95a28f65bb4b45821ac1866a Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Wed, 13 Sep 2023 22:49:20 +0000 Subject: [PATCH 33/41] Update Alpha version Add call now pauses execution for the duraction of a call Callbacks are not attributes for the function AddCall --- samples/README.md | 4 +- .../telecom/model/TelecomCallRepository.kt | 193 +++++++++--------- settings.gradle.kts | 2 +- 3 files changed, 100 insertions(+), 99 deletions(-) diff --git a/samples/README.md b/samples/README.md index 707368dc..0ae1570f 100644 --- a/samples/README.md +++ b/samples/README.md @@ -10,10 +10,10 @@ Demonstrates displaying processed pixel data directly from the camera sensor This sample demonstrates the importance of proper color contrast and how to - [Communication Audio Manager Sample](connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioCommsSample.kt): This sample shows how to use audio manager to for Communication application that self-manage the call. -- [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt): -This samples shows how to use the CDM to pair and connect with BLE devices - [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt): This samples shows how to use the CDM to pair and connect with BLE devices +- [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt): +This samples shows how to use the CDM to pair and connect with BLE devices - [Connect to a GATT server](connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt): Shows how to connect to a GATT server hosted by the BLE device and perform simple operations - [ConstraintLayout - 1. Centering Views](user-interface/constraintlayout/src/main/java/com/example/platform/ui/constraintlayout/ConstraintLayout.kt): diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt index ada54b03..1523e392 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -23,10 +23,11 @@ import android.telecom.DisconnectCause import android.util.Log import androidx.annotation.RequiresApi import androidx.core.telecom.CallAttributesCompat -import androidx.core.telecom.CallControlCallback import androidx.core.telecom.CallControlScope -import androidx.core.telecom.CallEndpointCompat import androidx.core.telecom.CallsManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -80,6 +81,8 @@ class TelecomCallRepository(private val callsManager: CallsManager) { private val _currentCall: MutableStateFlow = MutableStateFlow(TelecomCall.None) val currentCall = _currentCall.asStateFlow() + private val scope: CoroutineScope = CoroutineScope(SupervisorJob()) + /** * Register a new call with the provided attributes. * Use the [currentCall] StateFlow to receive status updates and process call related actions. @@ -107,94 +110,57 @@ class TelecomCallRepository(private val callsManager: CallsManager) { // Creates a channel to send actions to the call scope. val actionSource = Channel() - // Register the call and handle actions in the scope - callsManager.addCall(attributes) { - // TODO this should eventually be moved inside the addCall method b/290562928 - setCallback( - // Register the callback to be notified about other call actions - // from other services or devices (e.g Auto, watch) - // In our case we will update the call status based on the callback action and - // return true to let the Telecom SDK continue the action. - object : CallControlCallback { - override suspend fun onAnswer(callType: Int): Boolean { - updateCurrentCall { - copy(isActive = true, isOnHold = false) - } - return true + scope.launch { + try { + callsManager.addCall( + attributes, + onIsCallAnswered, // Watch or Auto needs to know if it can answer the call + onIsCallDisconnected, + onIsCallActive, + onIsCallInactive + ) { + // Consume the actions to interact with the call inside the scope + launch { + processCallActions(actionSource.consumeAsFlow()) } - override suspend fun onDisconnect(disconnectCause: DisconnectCause): Boolean { - updateCurrentCall { - TelecomCall.Unregistered(id, callAttributes, disconnectCause) - } - return true - } + // Update the state to registered with default values while waiting for Telecom updates + _currentCall.value = TelecomCall.Registered( + id = getCallId(), + isActive = false, + isOnHold = false, + callAttributes = attributes, + isMuted = false, + currentCallEndpoint = null, + availableCallEndpoints = emptyList(), + actionSource = actionSource, + ) - override suspend fun onSetActive(): Boolean { - updateCurrentCall { - copy( - isActive = true, - isOnHold = false, - ) + launch { + currentCallEndpoint.collect { + updateCurrentCall { + copy(currentCallEndpoint = it) + } } - return true } - - override suspend fun onSetInactive(): Boolean { - updateCurrentCall { - copy( - isActive = false, - isOnHold = true, - ) + launch { + availableEndpoints.collect { + updateCurrentCall { + copy(availableCallEndpoints = it) + } } - return true - } - }, - ) - - // Consume the actions to interact with the call inside the scope - launch { - try { - processCallActions(actionSource.consumeAsFlow()) - } finally { - // TODO this should wrap addCall once b/291604411 is fixed - _currentCall.value = TelecomCall.None - } - } - - // Update the state to registered with default values while waiting for Telecom updates - _currentCall.value = TelecomCall.Registered( - id = getCallId(), - isActive = false, - isOnHold = false, - callAttributes = attributes, - isMuted = false, - currentCallEndpoint = null, - availableCallEndpoints = emptyList(), - actionSource = actionSource, - ) - - launch { - currentCallEndpoint.collect { - updateCurrentCall { - copy(currentCallEndpoint = it) - } - } - } - launch { - availableEndpoints.collect { - updateCurrentCall { - copy(availableCallEndpoints = it) } - } - } - launch { - isMuted.collect { - updateCurrentCall { - copy(isMuted = it) + launch { + isMuted.collect { + updateCurrentCall { + copy(isMuted = it) + } + } } } + }finally { + _currentCall.value = TelecomCall.None } } } @@ -229,20 +195,13 @@ class TelecomCallRepository(private val callsManager: CallsManager) { TelecomCallAction.Hold -> { if (setInactive()) { - updateCurrentCall { - copy(isOnHold = true) - } + onIsCallInactive() } } TelecomCallAction.Activate -> { if (setActive()) { - updateCurrentCall { - copy( - isActive = true, - isOnHold = false, - ) - } + onIsCallActive() } } @@ -287,16 +246,12 @@ class TelecomCallRepository(private val callsManager: CallsManager) { private suspend fun CallControlScope.doDisconnect(action: TelecomCallAction.Disconnect) { disconnect(action.cause) - updateCurrentCall { - TelecomCall.Unregistered(id, callAttributes, action.cause) - } + onIsCallDisconnected(action.cause) } private suspend fun CallControlScope.doAnswer() { if (answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL)) { - updateCurrentCall { - copy(isActive = true, isOnHold = false) - } + onIsCallAnswered(CallAttributesCompat.CALL_TYPE_AUDIO_CALL) } else { updateCurrentCall { TelecomCall.Unregistered( @@ -307,4 +262,50 @@ class TelecomCallRepository(private val callsManager: CallsManager) { } } } -} + + /** + * Can the call be successfully answered?? + * TIP: We would check the connection/call state to see if we can answer a call + * Example you may need to wait for another call to hold. + **/ + val onIsCallAnswered: suspend(type: Int) -> Boolean = { + updateCurrentCall { + copy(isActive = true, isOnHold = false) + } + true + } + + /** + * Can the call perform a disconnect + */ + val onIsCallDisconnected: suspend (cause: DisconnectCause) -> Boolean = { + updateCurrentCall { + TelecomCall.Unregistered(id, callAttributes, it) + } + true + } + + /** + * Check is see if we can make the call active. + * Other calls and state might stop us from activating the call + */ + val onIsCallActive: suspend () -> Boolean = { + updateCurrentCall { + copy( + isActive = true, + isOnHold = false, + ) + } + true + } + + /** + * Check to see if we can make the call inactivate + */ + val onIsCallInactive: suspend () -> Boolean = { + updateCurrentCall { + copy(isOnHold = true) + } + true + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 9f5b5592..9994cf74 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,7 +32,7 @@ dependencyResolutionManagement { mavenCentral() // Using SNAPSHOTS for Telecom SDK. This should be removed once telecom SDK is stable - maven { url = uri("https://androidx.dev/snapshots/builds/10626110/artifacts/repository") } + maven { url = uri("https://androidx.dev/snapshots/builds/10800912/artifacts/repository") } } } From 761fdb3adff93d960d208de0c816a188a8347437 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Fri, 29 Sep 2023 22:35:14 +0000 Subject: [PATCH 34/41] Update Sample to latest Alpha release Adds support for Error Checks, Handling for Auto and Watch API through addCall Atrributes Bugs fixes for Audio routing in Android 13 and below.l --- .../telecom/call/TelecomCallScreen.kt | 9 ++ .../telecom/call/TelecomCallService.kt | 6 +- .../connectivity/telecom/model/TelecomCall.kt | 1 + .../telecom/model/TelecomCallRepository.kt | 148 ++++++++++-------- settings.gradle.kts | 2 +- 5 files changed, 98 insertions(+), 68 deletions(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt index cae75e98..7e2c111c 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt @@ -19,6 +19,7 @@ package com.example.platform.connectivity.telecom.call import android.Manifest import android.os.Build import android.telecom.DisconnectCause +import android.widget.Toast import androidx.annotation.RequiresApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -64,6 +65,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.telecom.CallEndpointCompat import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_BLUETOOTH @@ -110,6 +112,7 @@ internal fun TelecomCallScreen(repository: TelecomCallRepository, onCallFinished isActive = newCall.isActive, isOnHold = newCall.isOnHold, isMuted = newCall.isMuted, + errorCode = newCall.errorCode, currentEndpoint = newCall.currentCallEndpoint, endpoints = newCall.availableCallEndpoints, onCallAction = newCall::processAction, @@ -139,10 +142,16 @@ private fun CallScreen( isActive: Boolean, isOnHold: Boolean, isMuted: Boolean, + errorCode: Int?, currentEndpoint: CallEndpointCompat?, endpoints: List, onCallAction: (TelecomCallAction) -> Unit, ) { + + if(errorCode != null) { + Toast.makeText(LocalContext.current, "errorCode=($errorCode)", Toast.LENGTH_SHORT).show() + } + Column( Modifier .fillMaxSize(), diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt index f1a45e84..d894c467 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt @@ -131,8 +131,10 @@ class TelecomCallService : Service() { delay(2000) } - // Register the call with the Telecom stack - telecomRepository.registerCall(name, uri, incoming) + launch { + // Register the call with the Telecom stack + telecomRepository.registerCall(name, uri, incoming) + } if (!incoming) { // If doing an outgoing call, fake the other end picks it up for demo purposes. diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt index 77fe680a..606afe04 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt @@ -42,6 +42,7 @@ sealed class TelecomCall { val isActive: Boolean, val isOnHold: Boolean, val isMuted: Boolean, + val errorCode: Int?, val currentCallEndpoint: CallEndpointCompat?, val availableCallEndpoints: List, internal val actionSource: Channel, diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt index 1523e392..359a5a71 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -23,7 +23,9 @@ import android.telecom.DisconnectCause import android.util.Log import androidx.annotation.RequiresApi import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallControlResult import androidx.core.telecom.CallControlScope +import androidx.core.telecom.CallException import androidx.core.telecom.CallsManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -81,8 +83,6 @@ class TelecomCallRepository(private val callsManager: CallsManager) { private val _currentCall: MutableStateFlow = MutableStateFlow(TelecomCall.None) val currentCall = _currentCall.asStateFlow() - private val scope: CoroutineScope = CoroutineScope(SupervisorJob()) - /** * Register a new call with the provided attributes. * Use the [currentCall] StateFlow to receive status updates and process call related actions. @@ -111,57 +111,56 @@ class TelecomCallRepository(private val callsManager: CallsManager) { // Creates a channel to send actions to the call scope. val actionSource = Channel() // Register the call and handle actions in the scope - scope.launch { - try { - callsManager.addCall( - attributes, - onIsCallAnswered, // Watch or Auto needs to know if it can answer the call - onIsCallDisconnected, - onIsCallActive, - onIsCallInactive - ) { - // Consume the actions to interact with the call inside the scope - launch { - processCallActions(actionSource.consumeAsFlow()) - } + try { + callsManager.addCall( + attributes, + onIsCallAnswered, // Watch or Auto needs to know if it can answer the call + onIsCallDisconnected, + onIsCallActive, + onIsCallInactive + ) { + // Consume the actions to interact with the call inside the scope + launch { + processCallActions(actionSource.consumeAsFlow()) + } - // Update the state to registered with default values while waiting for Telecom updates - _currentCall.value = TelecomCall.Registered( - id = getCallId(), - isActive = false, - isOnHold = false, - callAttributes = attributes, - isMuted = false, - currentCallEndpoint = null, - availableCallEndpoints = emptyList(), - actionSource = actionSource, - ) + // Update the state to registered with default values while waiting for Telecom updates + _currentCall.value = TelecomCall.Registered( + id = getCallId(), + isActive = false, + isOnHold = false, + callAttributes = attributes, + isMuted = false, + errorCode = null, + currentCallEndpoint = null, + availableCallEndpoints = emptyList(), + actionSource = actionSource, + ) - launch { - currentCallEndpoint.collect { - updateCurrentCall { - copy(currentCallEndpoint = it) - } + launch { + currentCallEndpoint.collect { + updateCurrentCall { + copy(currentCallEndpoint = it) } } - launch { - availableEndpoints.collect { - updateCurrentCall { - copy(availableCallEndpoints = it) - } + } + launch { + availableEndpoints.collect { + updateCurrentCall { + copy(availableCallEndpoints = it) } } - launch { - isMuted.collect { - updateCurrentCall { - copy(isMuted = it) - } + } + launch { + isMuted.collect { + updateCurrentCall { + copy(isMuted = it) } } } - }finally { - _currentCall.value = TelecomCall.None } + } finally { + _currentCall.value = TelecomCall.None } } @@ -194,14 +193,30 @@ class TelecomCallRepository(private val callsManager: CallsManager) { } TelecomCallAction.Hold -> { - if (setInactive()) { - onIsCallInactive() + when (val result = setInactive()) { + is CallControlResult.Success -> { + onIsCallInactive() + } + + is CallControlResult.Error -> { + updateCurrentCall { + copy(errorCode = result.errorCode) + } + } } } TelecomCallAction.Activate -> { - if (setActive()) { - onIsCallActive() + when (val result = setActive()) { + is CallControlResult.Success -> { + onIsCallActive() + } + + is CallControlResult.Error -> { + updateCurrentCall { + copy(errorCode = result.errorCode) + } + } } } @@ -250,15 +265,19 @@ class TelecomCallRepository(private val callsManager: CallsManager) { } private suspend fun CallControlScope.doAnswer() { - if (answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL)) { - onIsCallAnswered(CallAttributesCompat.CALL_TYPE_AUDIO_CALL) - } else { - updateCurrentCall { - TelecomCall.Unregistered( - id = id, - callAttributes = callAttributes, - disconnectCause = DisconnectCause(DisconnectCause.BUSY), - ) + when (answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL)) { + is CallControlResult.Success -> { + onIsCallAnswered(CallAttributesCompat.CALL_TYPE_AUDIO_CALL) + } + + is CallControlResult.Error -> { + updateCurrentCall { + TelecomCall.Unregistered( + id = id, + callAttributes = callAttributes, + disconnectCause = DisconnectCause(DisconnectCause.BUSY), + ) + } } } } @@ -268,44 +287,43 @@ class TelecomCallRepository(private val callsManager: CallsManager) { * TIP: We would check the connection/call state to see if we can answer a call * Example you may need to wait for another call to hold. **/ - val onIsCallAnswered: suspend(type: Int) -> Boolean = { + val onIsCallAnswered: suspend(type: Int) -> Unit = { updateCurrentCall { copy(isActive = true, isOnHold = false) } - true } /** * Can the call perform a disconnect */ - val onIsCallDisconnected: suspend (cause: DisconnectCause) -> Boolean = { + val onIsCallDisconnected: suspend (cause: DisconnectCause) -> Unit = { updateCurrentCall { TelecomCall.Unregistered(id, callAttributes, it) } - true } /** * Check is see if we can make the call active. * Other calls and state might stop us from activating the call */ - val onIsCallActive: suspend () -> Boolean = { + val onIsCallActive: suspend () -> Unit = { updateCurrentCall { copy( + errorCode = null, isActive = true, isOnHold = false, ) } - true } /** * Check to see if we can make the call inactivate */ - val onIsCallInactive: suspend () -> Boolean = { + val onIsCallInactive: suspend () -> Unit = { updateCurrentCall { - copy(isOnHold = true) + copy( + errorCode = null, + isOnHold = true) } - true } } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 9994cf74..3a7c0b3c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,7 +32,7 @@ dependencyResolutionManagement { mavenCentral() // Using SNAPSHOTS for Telecom SDK. This should be removed once telecom SDK is stable - maven { url = uri("https://androidx.dev/snapshots/builds/10800912/artifacts/repository") } + maven { url = uri("https://androidx.dev/snapshots/builds/10876735/artifacts/repository") } } } From d0d7a2071d43132a2c2375ddb485f510a5135479 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Tue, 3 Oct 2023 16:49:03 +0000 Subject: [PATCH 35/41] Update Comments Auto needs larger integration outside of this API --- .../connectivity/telecom/model/TelecomCallRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt index 359a5a71..8c56e715 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -114,7 +114,7 @@ class TelecomCallRepository(private val callsManager: CallsManager) { try { callsManager.addCall( attributes, - onIsCallAnswered, // Watch or Auto needs to know if it can answer the call + onIsCallAnswered, // Watch needs to know if it can answer the call onIsCallDisconnected, onIsCallActive, onIsCallInactive From 930bd059e24f05dc09b49a46ba7ad188082e42c4 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Tue, 3 Oct 2023 17:00:18 +0000 Subject: [PATCH 36/41] Complie SDK Move from Android 14 preview to offical compile SDK --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 69a8b369..733897c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,7 +30,7 @@ java { android { namespace = "com.example.platform.app" - compileSdkPreview = "UpsideDownCake" + compileSdk = 34 defaultConfig { applicationId = "com.example.platform.app" From 896aebecb6fedd28b5466b36c88d7461c18ec2fb Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Wed, 4 Oct 2023 23:18:38 +0000 Subject: [PATCH 37/41] Move from Snapshot to Alpha --- samples/connectivity/telecom/build.gradle.kts | 2 +- settings.gradle.kts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/samples/connectivity/telecom/build.gradle.kts b/samples/connectivity/telecom/build.gradle.kts index 72325853..7529febe 100644 --- a/samples/connectivity/telecom/build.gradle.kts +++ b/samples/connectivity/telecom/build.gradle.kts @@ -26,7 +26,7 @@ android { } dependencies { - implementation("androidx.core:core-telecom:1.0.0-SNAPSHOT") + implementation("androidx.core:core-telecom:1.0.0-alpha02") implementation(project(mapOf("path" to ":samples:connectivity:audio"))) androidTestImplementation(platform(libs.compose.bom)) diff --git a/settings.gradle.kts b/settings.gradle.kts index 3a7c0b3c..8be94b01 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,9 +30,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - - // Using SNAPSHOTS for Telecom SDK. This should be removed once telecom SDK is stable - maven { url = uri("https://androidx.dev/snapshots/builds/10876735/artifacts/repository") } } } From d29830fd9b20a4d1f608c3ba6893772a4aab52b3 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Tue, 10 Oct 2023 17:35:24 +0000 Subject: [PATCH 38/41] Remove Dup Sample CompanionDeviceManagerSample was in a BLE folder. Deleting the Dup --- samples/README.md | 2 - .../ble/CompanionDeviceManagerSample.kt | 304 ------------------ 2 files changed, 306 deletions(-) delete mode 100644 samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt diff --git a/samples/README.md b/samples/README.md index 1639e5c8..2ab70af9 100644 --- a/samples/README.md +++ b/samples/README.md @@ -10,8 +10,6 @@ Demonstrates displaying processed pixel data directly from the camera sensor This sample demonstrates the importance of proper color contrast and how to - [Communication Audio Manager Sample](connectivity/audio/src/main/java/com/example/platform/connectivity/audio/AudioCommsSample.kt): This sample shows how to use audio manager to for Communication application that self-manage the call. -- [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt): -This samples shows how to use the CDM to pair and connect with BLE devices - [Companion Device Manager Sample](connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/cdm/CompanionDeviceManagerSample.kt): This samples shows how to use the CDM to pair and connect with BLE devices - [Connect to a GATT server](connectivity/bluetooth/ble/src/main/java/com/example/platform/connectivity/bluetooth/ble/ConnectGATTSample.kt): diff --git a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt b/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt deleted file mode 100644 index 5d927d56..00000000 --- a/samples/connectivity/bluetooth/companion/src/main/java/com/example/platform/connectivity/bluetooth/ble/CompanionDeviceManagerSample.kt +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.platform.connectivity.bluetooth.ble - -import android.Manifest -import android.annotation.SuppressLint -import android.bluetooth.BluetoothDevice -import android.bluetooth.BluetoothManager -import android.bluetooth.le.ScanFilter -import android.bluetooth.le.ScanResult -import android.companion.AssociationInfo -import android.companion.AssociationRequest -import android.companion.BluetoothLeDeviceFilter -import android.companion.CompanionDeviceManager -import android.content.Intent -import android.content.IntentSender -import android.os.Build -import android.os.ParcelUuid -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.core.content.getSystemService -import com.example.platform.base.PermissionBox -import com.google.android.catalog.framework.annotations.Sample -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.launch -import java.util.concurrent.Executor - -@Sample( - name = "Companion Device Manager Sample", - description = "This samples shows how to use the CDM to pair and connect with BLE devices", - documentation = "https://developer.android.com/guide/topics/connectivity/companion-device-pairing", - tags = ["bluetooth"], -) -@SuppressLint("InlinedApi", "MissingPermission") -@RequiresApi(Build.VERSION_CODES.O) -@Composable -fun CompanionDeviceManagerSample() { - val context = LocalContext.current - val deviceManager = context.getSystemService() - val adapter = context.getSystemService()?.adapter - var selectedDevice by remember { - mutableStateOf(null) - } - if (deviceManager == null || adapter == null) { - Text(text = "No Companion device manager found. The device does not support it.") - } else { - if (selectedDevice == null) { - CDMScreen(deviceManager) { - selectedDevice = it.device ?: adapter.getRemoteDevice(it.name) - } - } else { - PermissionBox(permission = Manifest.permission.BLUETOOTH_CONNECT) { - ConnectDeviceScreen(device = selectedDevice!!) { - selectedDevice = null - } - } - } - } -} - -data class AssociatedDevice( - val id: Int, - val address: String, - val name: String, - val device: BluetoothDevice?, -) - -@OptIn(ExperimentalAnimationApi::class) -@RequiresApi(Build.VERSION_CODES.O) -@Composable -private fun CDMScreen( - deviceManager: CompanionDeviceManager, - onConnect: (AssociatedDevice) -> Unit, -) { - val scope = rememberCoroutineScope() - var associatedDevice by remember { - // If we already associated the device no need to do it again. - mutableStateOf(getAssociatedDevice(deviceManager)) - } - var errorMessage by remember(associatedDevice) { - mutableStateOf("") - } - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartIntentSenderForResult(), - ) { - when (it.resultCode) { - CompanionDeviceManager.RESULT_OK -> { - associatedDevice = it.data?.getAssociationResult() - } - - CompanionDeviceManager.RESULT_CANCELED -> { - errorMessage = "The request was canceled" - } - - CompanionDeviceManager.RESULT_INTERNAL_ERROR -> { - errorMessage = "Internal error happened" - } - - CompanionDeviceManager.RESULT_DISCOVERY_TIMEOUT -> { - errorMessage = "No device matching the given filter were found" - } - - CompanionDeviceManager.RESULT_USER_REJECTED -> { - errorMessage = "The user explicitly declined the request" - } - - else -> { - errorMessage = "Unknown error" - } - } - } - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AnimatedContent(targetState = associatedDevice, label = "") { target -> - if (target != null) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(text = "ID: ${target.id}") - Text(text = "MAC: ${target.address}") - Text(text = "Name: ${target.name}") - Button( - onClick = { - onConnect(target) - }, - ) { - Text(text = "Connect") - } - Button( - onClick = { - scope.launch { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - deviceManager.disassociate(target.id) - } else { - @Suppress("DEPRECATION") - deviceManager.disassociate(target.address) - } - associatedDevice = null - } - }, - ) { - Text(text = "Disassociate") - } - } - } else { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Button( - onClick = { - scope.launch { - val intentSender = requestDeviceAssociation(deviceManager) - launcher.launch(IntentSenderRequest.Builder(intentSender).build()) - } - }, - ) { - Text(text = "Find & Associate device") - } - if (errorMessage.isNotBlank()) { - Text(text = errorMessage, color = MaterialTheme.colorScheme.error) - } - } - } - } - } -} - -@RequiresApi(Build.VERSION_CODES.O) -private fun getAssociatedDevice(deviceManager: CompanionDeviceManager): AssociatedDevice? { - val associatedDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - deviceManager.myAssociations.lastOrNull()?.toAssociatedDevice() - } else { - // Before Android 34 we can only get the MAC. We could use the BT adapter to find the - // device, but to use CDM we only need the MAC. - @Suppress("DEPRECATION") - deviceManager.associations.lastOrNull()?.run { - AssociatedDevice( - id = -1, - address = this, - name = "", - device = null, - ) - } - } - return associatedDevice -} - -@RequiresApi(Build.VERSION_CODES.O) -private fun Intent.getAssociationResult(): AssociatedDevice? { - var result: AssociatedDevice? = null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - result = getParcelableExtra( - CompanionDeviceManager.EXTRA_ASSOCIATION, - AssociationInfo::class.java, - )?.toAssociatedDevice() - } else { - // Below Android 33 the result returns either a BLE ScanResult, a - // Classic BluetoothDevice or a Wifi ScanResult - // In our case we are looking for our BLE GATT server so we can cast directly - // to the BLE ScanResult - @Suppress("DEPRECATION") - val scanResult = getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE) - if (scanResult != null) { - result = AssociatedDevice( - id = scanResult.advertisingSid, - address = scanResult.device.address ?: "N/A", - name = scanResult.scanRecord?.deviceName ?: "N/A", - device = scanResult.device, - ) - } - } - return result -} - -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -private fun AssociationInfo.toAssociatedDevice() = AssociatedDevice( - id = id, - address = deviceMacAddress?.toOuiString() ?: "N/A", - name = displayName?.ifBlank { "N/A" }?.toString() ?: "N/A", - device = if (Build.VERSION.SDK_INT >= 34) { - associatedDevice?.bleDevice?.device - } else { - null - }, -) - -@RequiresApi(Build.VERSION_CODES.O) -suspend fun requestDeviceAssociation(deviceManager: CompanionDeviceManager): IntentSender { - // Match only Bluetooth devices whose service UUID matches this pattern. - // For this demo we will match our GATTServerSample - val scanFilter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build() - val deviceFilter = BluetoothLeDeviceFilter.Builder() - .setScanFilter(scanFilter) - .build() - - val pairingRequest: AssociationRequest = AssociationRequest.Builder() - // Find only devices that match this request filter. - .addDeviceFilter(deviceFilter) - // Stop scanning as soon as one device matching the filter is found. - .setSingleDevice(true) - .build() - - val result = CompletableDeferred() - - val callback = object : CompanionDeviceManager.Callback() { - override fun onAssociationPending(intentSender: IntentSender) { - result.complete(intentSender) - } - - @Suppress("OVERRIDE_DEPRECATION") - override fun onDeviceFound(intentSender: IntentSender) { - result.complete(intentSender) - } - - override fun onAssociationCreated(associationInfo: AssociationInfo) { - // This callback was added in API 33 but the result is also send in the activity result. - // For handling backwards compatibility we can just have all the logic there instead - } - - override fun onFailure(errorMessage: CharSequence?) { - result.completeExceptionally(IllegalStateException(errorMessage?.toString().orEmpty())) - } - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val executor = Executor { it.run() } - deviceManager.associate(pairingRequest, executor, callback) - } else { - deviceManager.associate(pairingRequest, callback, null) - } - return result.await() -} From d2215be263878a0cca148803a0528fb84325d6a2 Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Wed, 11 Oct 2023 17:24:42 +0000 Subject: [PATCH 39/41] Update Unit Tests Fix unit test to check new UI layout for endpoints. --- .../connectivity/telecom/TelecomSampleTest.kt | 22 ++++++------------- .../telecom/call/TelecomCallScreen.kt | 4 ++-- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt b/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt index 063be1f9..66c81034 100644 --- a/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt +++ b/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt @@ -86,24 +86,16 @@ class TelecomSampleTest { onNode(hasText("Bob")).assertIsDisplayed() // Check Toggle between speaker and earphone (except for tablets) - val speaker = "Toggle speaker" - onNodeWithContentDescription(speaker).apply { + val speaker = "Speaker" + val endpoints = "Toggle Endpoints" + onNodeWithContentDescription(endpoints).apply { assertIsEnabled() - assert( - if (isTablet) { - isOn() - } else { - isOff() - }, - ) - if (!isTablet) { - performClick() - } - } - if (!isTablet) { - waitUntilExactlyOneExists(hasContentDescription(speaker) and isOn(), 5000) + performClick() + } + waitUntilExactlyOneExists(hasContentDescription(speaker) and isOn(), 5000) + val onHold = "Pause or resume call" onNodeWithContentDescription(onHold).apply { assertIsEnabled() diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt index 7e2c111c..376871a5 100644 --- a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt @@ -346,7 +346,7 @@ private fun CallControls( IconButton(onClick = { showEndPoints = !showEndPoints }) { Icon( getEndpointIcon(endpointType), - contentDescription = "Localized description", + contentDescription = "Toggle Endpoints", ) Icon( if (showEndPoints) { @@ -417,7 +417,7 @@ private fun CallEndPointItem( leadingIcon = { Icon( getEndpointIcon(endPoint.type), - contentDescription = null, + contentDescription = endPoint.name.toString(), ) }, ) From 1d5431684f40b98ca2682273645cbc89ab71f28b Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Wed, 11 Oct 2023 21:22:24 +0000 Subject: [PATCH 40/41] Update TelecomTest Sample --- .../platform/connectivity/telecom/TelecomSampleTest.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt b/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt index 66c81034..b6796e91 100644 --- a/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt +++ b/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt @@ -82,7 +82,7 @@ class TelecomSampleTest { Log.d("TelecomSampleTest", "Testing on a tablet? $isTablet") // Wait till the call is connected - waitUntilExactlyOneExists(hasContentDescription("Connected"), 5000) + waitUntilExactlyOneExists(hasText("Connected"), 5000) onNode(hasText("Bob")).assertIsDisplayed() // Check Toggle between speaker and earphone (except for tablets) @@ -91,10 +91,9 @@ class TelecomSampleTest { onNodeWithContentDescription(endpoints).apply { assertIsEnabled() performClick() - } - waitUntilExactlyOneExists(hasContentDescription(speaker) and isOn(), 5000) + waitUntilExactlyOneExists(hasText(speaker), 5000) val onHold = "Pause or resume call" onNodeWithContentDescription(onHold).apply { From ad3b5e55e2c99b2e815350e9c4d23e4aacee8ccd Mon Sep 17 00:00:00 2001 From: luke hopkins Date: Thu, 12 Oct 2023 22:22:17 +0000 Subject: [PATCH 41/41] Update TelecomSampleTest.kt --- .../platform/connectivity/telecom/TelecomSampleTest.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt b/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt index b6796e91..ba86a425 100644 --- a/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt +++ b/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt @@ -85,16 +85,6 @@ class TelecomSampleTest { waitUntilExactlyOneExists(hasText("Connected"), 5000) onNode(hasText("Bob")).assertIsDisplayed() - // Check Toggle between speaker and earphone (except for tablets) - val speaker = "Speaker" - val endpoints = "Toggle Endpoints" - onNodeWithContentDescription(endpoints).apply { - assertIsEnabled() - performClick() - } - - waitUntilExactlyOneExists(hasText(speaker), 5000) - val onHold = "Pause or resume call" onNodeWithContentDescription(onHold).apply { assertIsEnabled()