From 282ec6918bafbbf083e977732d01d1b29805024f Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 23 Aug 2024 15:52:16 -0300 Subject: [PATCH] Add call audio toggle to calling v2. --- .../webrtc/ToggleButtonOutputState.kt | 22 ++ .../components/webrtc/WebRtcAudioPicker31.kt | 44 ++- .../components/webrtc/v2/CallActivity.kt | 9 + .../webrtc/v2/CallAudioToggleButton.kt | 323 ++++++++++++++++++ .../components/webrtc/v2/CallButton.kt | 14 +- .../components/webrtc/v2/CallControls.kt | 38 ++- .../components/webrtc/v2/CallScreen.kt | 5 +- .../components/webrtc/v2/CallScreenState.kt | 7 +- .../components/webrtc/v2/CallViewModel.kt | 26 ++ .../symbol_speaker_bluetooth_outline_24.xml | 12 + 10 files changed, 488 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt create mode 100644 app/src/main/res/drawable/symbol_speaker_bluetooth_outline_24.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/ToggleButtonOutputState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/ToggleButtonOutputState.kt index 092b8b17d0..5f3d028949 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/ToggleButtonOutputState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/ToggleButtonOutputState.kt @@ -1,5 +1,8 @@ package org.thoughtcrime.securesms.components.webrtc +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import kotlin.math.min /** @@ -13,15 +16,30 @@ class ToggleButtonOutputState { throw IndexOutOfBoundsException("Index: $value, size: ${availableOutputs.size}") } field = value + currentDevice = getCurrentOutput() } + /** + * Observable state of currently selected device. + */ + var currentDevice by mutableStateOf(getCurrentOutput()) + private set + + /** + * Observable state of available devices. + */ + var availableDevices by mutableStateOf(getOutputs()) + private set + var isEarpieceAvailable: Boolean get() = availableOutputs.contains(WebRtcAudioOutput.HANDSET) set(value) { if (value) { availableOutputs.add(WebRtcAudioOutput.HANDSET) + availableDevices = getOutputs() } else { availableOutputs.remove(WebRtcAudioOutput.HANDSET) + availableDevices = getOutputs() selectedDevice = min(selectedDevice, availableOutputs.size - 1) } } @@ -31,8 +49,10 @@ class ToggleButtonOutputState { set(value) { if (value) { availableOutputs.add(WebRtcAudioOutput.BLUETOOTH_HEADSET) + availableDevices = getOutputs() } else { availableOutputs.remove(WebRtcAudioOutput.BLUETOOTH_HEADSET) + availableDevices = getOutputs() selectedDevice = min(selectedDevice, availableOutputs.size - 1) } } @@ -41,8 +61,10 @@ class ToggleButtonOutputState { set(value) { if (value) { availableOutputs.add(WebRtcAudioOutput.WIRED_HEADSET) + availableDevices = getOutputs() } else { availableOutputs.remove(WebRtcAudioOutput.WIRED_HEADSET) + availableDevices = getOutputs() selectedDevice = min(selectedDevice, availableOutputs.size - 1) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt index 779896af26..1e93f457f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioPicker31.kt @@ -5,7 +5,13 @@ import android.content.DialogInterface import android.media.AudioDeviceInfo import android.widget.Toast import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource import androidx.fragment.app.FragmentActivity +import kotlinx.collections.immutable.toImmutableList import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -33,7 +39,7 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC val devices: List = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(fragmentActivity).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }.distinctBy { it.deviceType.name }.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE } val currentDeviceId = am.communicationDevice?.id ?: -1 if (devices.size < threshold) { - Log.d(TAG, "Only found $devices devices,\nnot showing picker.") + Log.d(TAG, "Only found $devices devices, not showing picker.") if (devices.isEmpty()) return null val index = devices.indexOfFirst { it.deviceId == currentDeviceId } @@ -42,11 +48,45 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC onAudioDeviceSelected(devices[(index + 1) % devices.size]) return null } else { - Log.d(TAG, "Found $devices devices,\nshowing picker.") + Log.d(TAG, "Found $devices devices, showing picker.") return WebRtcAudioOutputBottomSheet.show(fragmentActivity.supportFragmentManager, devices, currentDeviceId, onAudioDeviceSelected, onDismiss) } } + @Composable + fun Picker(threshold: Int) { + val context = LocalContext.current + + val am = AppDependencies.androidCallAudioManager + if (am.availableCommunicationDevices.isEmpty()) { + Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show() + return + } + + val devices: List = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(context).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }.distinctBy { it.deviceType.name }.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE } + val currentDeviceId = am.communicationDevice?.id ?: -1 + if (devices.size < threshold) { + Log.d(TAG, "Only found $devices devices, not showing picker.") + if (devices.isEmpty()) return + + val index = devices.indexOfFirst { it.deviceId == currentDeviceId } + if (index == -1) return + + onAudioDeviceSelected(devices[(index + 1) % devices.size]) + return + } else { + Log.d(TAG, "Found $devices devices, showing picker.") + DeviceList( + audioOutputOptions = devices.toImmutableList(), + initialDeviceId = currentDeviceId, + onDeviceSelected = onAudioDeviceSelected, + modifier = Modifier.padding( + horizontal = dimensionResource(id = R.dimen.core_ui__gutter) + ) + ) + } + } + @RequiresApi(31) val onAudioDeviceSelected: (AudioOutputOption) -> Unit = { Log.d(TAG, "User selected audio device of type ${it.deviceType}") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallActivity.kt index 70cb5dcd63..8308e24dfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallActivity.kt @@ -35,6 +35,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.BaseActivity import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel @@ -263,6 +264,14 @@ class CallActivity : BaseActivity(), CallControlsCallback { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) } + override fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) { + viewModel.onAudioDeviceSheetDisplayChanged(displayed) + } + + override fun onSelectedAudioDeviceChanged(audioDevice: WebRtcAudioDevice) { + viewModel.onSelectedAudioDeviceChanged(audioDevice) + } + override fun onVideoToggleClick(enabled: Boolean) { if (webRtcCallViewModel.recipient.get() != Recipient.UNKNOWN) { callPermissionsDialogController.requestCameraPermission( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt new file mode 100644 index 0000000000..74587631f5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallAudioToggleButton.kt @@ -0,0 +1,323 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.DarkPreview +import org.signal.core.ui.IconButtons +import org.signal.core.ui.Previews +import org.signal.core.ui.Rows +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.webrtc.AudioStateUpdater +import org.thoughtcrime.securesms.components.webrtc.ToggleButtonOutputState +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioPicker31 + +private const val SHOW_PICKER_THRESHOLD = 3 + +/** + * Button which allows user to select from different audio devices to play back call audio through. + */ +@Composable +fun CallAudioToggleButton( + outputState: ToggleButtonOutputState, + contentDescription: String, + onSelectedDeviceChanged: (WebRtcAudioDevice) -> Unit, + onSheetDisplayChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val buttonSize = dimensionResource(id = R.dimen.webrtc_button_size) + + val currentOutput = outputState.currentDevice + val allOutputs = outputState.availableDevices + + val containerColor = if (currentOutput == WebRtcAudioOutput.HANDSET || allOutputs.size >= SHOW_PICKER_THRESHOLD) { + MaterialTheme.colorScheme.secondaryContainer + } else { + colorResource(id = R.color.signal_light_colorSecondaryContainer) + } + + val contentColor = if (currentOutput == WebRtcAudioOutput.HANDSET || allOutputs.size >= SHOW_PICKER_THRESHOLD) { + colorResource(id = R.color.signal_light_colorOnPrimary) + } else { + colorResource(id = R.color.signal_light_colorOnSecondaryContainer) + } + + val pickerController = rememberPickerController( + onSelectedDeviceChanged = onSelectedDeviceChanged, + outputState = outputState + ) + + IconButtons.IconButton( + size = buttonSize, + onClick = { + pickerController.show() + }, + colors = IconButtons.iconButtonColors( + containerColor = containerColor, + contentColor = contentColor + ), + modifier = modifier.size(buttonSize) + ) { + val iconRes = remember(currentOutput, pickerController.willDisplayPicker) { + if (!pickerController.willDisplayPicker && currentOutput == WebRtcAudioOutput.HANDSET) { + WebRtcAudioOutput.SPEAKER.iconRes + } else { + currentOutput.iconRes + } + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = contentDescription, + modifier = Modifier.size(28.dp) + ) + + if (pickerController.willDisplayPicker) { + Icon( + painter = painterResource(id = R.drawable.symbol_dropdown_triangle_compat_bold_16), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } + } + + pickerController.Sheet() + + LaunchedEffect(pickerController.displaySheet) { + onSheetDisplayChanged(pickerController.displaySheet) + } +} + +@Composable +private fun rememberPickerController( + onSelectedDeviceChanged: (WebRtcAudioDevice) -> Unit, + outputState: ToggleButtonOutputState +): PickerController { + return remember(onSelectedDeviceChanged, outputState) { + PickerController( + onSelectedDeviceChanged, + outputState + ) + } +} + +/** + * Controller for the Audio picker which contains different state variables for choosing whether + * or not to display the sheet. + */ +private class PickerController( + private val onSelectedDeviceChanged: (WebRtcAudioDevice) -> Unit, + private val outputState: ToggleButtonOutputState +) { + + var displaySheet: Boolean by mutableStateOf(false) + private set + + val willDisplayPicker: Boolean by derivedStateOf { + outputState.availableDevices.size >= SHOW_PICKER_THRESHOLD || !outputState.availableDevices.contains(WebRtcAudioOutput.HANDSET) + } + + private val newApiController = if (Build.VERSION.SDK_INT >= 31) WebRtcAudioPicker31( + audioOutputChangedListener = onSelectedDeviceChanged, + outputState = outputState, + stateUpdater = object : AudioStateUpdater { + override fun updateAudioOutputState(audioOutput: WebRtcAudioOutput) { + outputState.setCurrentOutput(audioOutput) + displaySheet = false + } + + override fun hidePicker() { + displaySheet = false + } + } + ) else null + + fun show() { + displaySheet = true + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun Sheet() { + if (!displaySheet) { + return + } + + val isLegacy = Build.VERSION.SDK_INT < 31 + if (!willDisplayPicker) { + if (isLegacy) { + onSelectedDeviceChanged(WebRtcAudioDevice(outputState.peekNext(), null)) + } else { + newApiController!!.Picker(threshold = SHOW_PICKER_THRESHOLD) + } + return + } + + ModalBottomSheet( + onDismissRequest = { displaySheet = false } + ) { + if (isLegacy) { + LegacyAudioPickerContent( + toggleButtonOutputState = outputState, + onSelectedDeviceChanged = { + displaySheet = false + onSelectedDeviceChanged(WebRtcAudioDevice(it, null)) + } + ) + } else { + newApiController!!.Picker(threshold = SHOW_PICKER_THRESHOLD) + } + } + } +} + +/** + * Picker for pre-api-31 devices. + */ +@Composable +private fun LegacyAudioPickerContent( + toggleButtonOutputState: ToggleButtonOutputState, + onSelectedDeviceChanged: (WebRtcAudioOutput) -> Unit +) { + Text( + text = stringResource(R.string.WebRtcAudioOutputToggle__audio_output), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(8.dp) + .padding( + horizontal = dimensionResource(id = R.dimen.core_ui__gutter) + ) + ) + + LazyColumn( + modifier = Modifier.padding( + horizontal = dimensionResource(id = R.dimen.core_ui__gutter) + ) + ) { + items( + items = toggleButtonOutputState.availableDevices + ) { item -> + val icon = when (item) { + WebRtcAudioOutput.HANDSET -> R.drawable.symbol_phone_speaker_outline_24 + WebRtcAudioOutput.SPEAKER -> R.drawable.symbol_speaker_outline_24 + WebRtcAudioOutput.BLUETOOTH_HEADSET -> R.drawable.symbol_speaker_bluetooth_outline_24 + WebRtcAudioOutput.WIRED_HEADSET -> R.drawable.symbol_headphones_outline_24 + } + + Rows.RadioRow( + selected = item == toggleButtonOutputState.currentDevice, + content = { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.padding(end = 16.dp) + ) + + Text( + text = stringResource(id = item.labelRes), + style = MaterialTheme.typography.bodyLarge + ) + }, + modifier = Modifier.clickable { + onSelectedDeviceChanged(item) + } + ) + } + } +} + +@DarkPreview +@Composable +private fun CallAudioPickerSheetContentPreview() { + Previews.BottomSheetPreview { + Column { + LegacyAudioPickerContent( + toggleButtonOutputState = ToggleButtonOutputState().apply { + isEarpieceAvailable = true + isBluetoothHeadsetAvailable = true + isWiredHeadsetAvailable = true + }, + onSelectedDeviceChanged = {} + ) + } + } +} + +@DarkPreview +@Composable +private fun TwoDeviceCallAudioToggleButtonPreview() { + val outputState = remember { + ToggleButtonOutputState().apply { + isEarpieceAvailable = true + } + } + + Previews.Preview { + CallAudioToggleButton( + outputState = outputState, + contentDescription = "", + onSelectedDeviceChanged = { + outputState.setCurrentOutput(it.webRtcAudioOutput) + }, + onSheetDisplayChanged = {} + ) + } +} + +@DarkPreview +@Composable +private fun ThreeDeviceCallAudioToggleButtonPreview() { + val outputState = remember { + ToggleButtonOutputState().apply { + isEarpieceAvailable = true + isBluetoothHeadsetAvailable = true + } + } + + Previews.Preview { + CallAudioToggleButton( + outputState = outputState, + contentDescription = "", + onSelectedDeviceChanged = { + outputState.setCurrentOutput(it.webRtcAudioOutput) + }, + onSheetDisplayChanged = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallButton.kt index e29d690132..a18a98be35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallButton.kt @@ -43,12 +43,14 @@ private fun ToggleCallButton( onCheckedChange = onCheckedChange, size = buttonSize, modifier = modifier.size(buttonSize), - colors = IconButtons.iconToggleButtonColors( - checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer, - checkedContentColor = colorResource(id = R.color.signal_light_colorOnPrimary), - containerColor = colorResource(id = R.color.signal_light_colorSecondaryContainer), - contentColor = colorResource(id = R.color.signal_light_colorOnSecondaryContainer) - ) + colors = IconButtons.run { + iconToggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + checkedContentColor = colorResource(id = R.color.signal_light_colorOnPrimary), + containerColor = colorResource(id = R.color.signal_light_colorSecondaryContainer), + contentColor = colorResource(id = R.color.signal_light_colorOnSecondaryContainer) + ) + } ) { Icon( painter = if (checked) checkedPainter else painter, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt index aa69fbfa9c..314263da10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -31,6 +32,8 @@ import org.signal.core.ui.DarkPreview import org.signal.core.ui.Previews import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState +import org.thoughtcrime.securesms.components.webrtc.ToggleButtonOutputState +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput import org.thoughtcrime.securesms.components.webrtc.WebRtcControls import org.thoughtcrime.securesms.events.WebRtcViewModel @@ -66,7 +69,30 @@ fun CallControls( Row( horizontalArrangement = spacedBy(20.dp) ) { - // TODO [alex] -- Audio output toggle + if (callControlsState.displayAudioOutputToggle) { + val outputState = remember { + ToggleButtonOutputState().apply { + isEarpieceAvailable = callControlsState.isEarpieceAvailable + isWiredHeadsetAvailable = callControlsState.isWiredHeadsetAvailable + isBluetoothHeadsetAvailable = callControlsState.isBluetoothHeadsetAvailable + } + } + + LaunchedEffect(callControlsState.isEarpieceAvailable, callControlsState.isWiredHeadsetAvailable, callControlsState.isBluetoothHeadsetAvailable) { + outputState.apply { + isEarpieceAvailable = callControlsState.isEarpieceAvailable + isWiredHeadsetAvailable = callControlsState.isWiredHeadsetAvailable + isBluetoothHeadsetAvailable = callControlsState.isBluetoothHeadsetAvailable + } + } + + CallAudioToggleButton( + outputState = outputState, + contentDescription = stringResource(id = R.string.WebRtcAudioOutputToggle__audio_output), + onSelectedDeviceChanged = callControlsCallback::onSelectedAudioDeviceChanged, + onSheetDisplayChanged = callControlsCallback::onAudioDeviceSheetDisplayChanged + ) + } val hasCameraPermission = ContextCompat.checkSelfPermission(LocalContext.current, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED if (callControlsState.displayVideoToggle) { @@ -145,6 +171,8 @@ fun CallControlsPreview() { * Callbacks for call controls actions. */ interface CallControlsCallback { + fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) + fun onSelectedAudioDeviceChanged(audioDevice: WebRtcAudioDevice) fun onVideoToggleClick(enabled: Boolean) fun onMicToggleClick(enabled: Boolean) fun onGroupRingingToggleClick(enabled: Boolean, allowed: Boolean) @@ -153,6 +181,8 @@ interface CallControlsCallback { fun onEndCallClick() object Empty : CallControlsCallback { + override fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) = Unit + override fun onSelectedAudioDeviceChanged(audioDevice: WebRtcAudioDevice) = Unit override fun onVideoToggleClick(enabled: Boolean) = Unit override fun onMicToggleClick(enabled: Boolean) = Unit override fun onGroupRingingToggleClick(enabled: Boolean, allowed: Boolean) = Unit @@ -168,6 +198,9 @@ interface CallControlsCallback { * sources so we don't need to listen to multiple here. */ data class CallControlsState( + val isEarpieceAvailable: Boolean = false, + val isBluetoothHeadsetAvailable: Boolean = false, + val isWiredHeadsetAvailable: Boolean = false, val skipHiddenState: Boolean = true, val displayAudioOutputToggle: Boolean = false, val audioOutput: WebRtcAudioOutput = WebRtcAudioOutput.HANDSET, @@ -200,6 +233,9 @@ data class CallControlsState( } return CallControlsState( + isEarpieceAvailable = webRtcControls.isEarpieceAvailableForAudioToggle, + isBluetoothHeadsetAvailable = webRtcControls.isBluetoothHeadsetAvailableForAudioToggle, + isWiredHeadsetAvailable = webRtcControls.isWiredHeadsetAvailableForAudioToggle, skipHiddenState = !(webRtcControls.isFadeOutEnabled || webRtcControls == WebRtcControls.PIP || webRtcControls.displayErrorControls()), displayAudioOutputToggle = webRtcControls.displayAudioToggle(), audioOutput = webRtcControls.audioOutput, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt index fd9fe93870..179d5f5c58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt @@ -177,6 +177,7 @@ fun CallScreen( overflowParticipants = overflowParticipants, scaffoldState = scaffoldState, callControlsState = callControlsState, + callScreenState = callScreenState, onPipClick = onLocalPictureInPictureClicked, onControlsToggled = onControlsToggled ) @@ -196,6 +197,7 @@ fun CallScreen( overflowParticipants = overflowParticipants, scaffoldState = scaffoldState, callControlsState = callControlsState, + callScreenState = callScreenState, onPipClick = onLocalPictureInPictureClicked, onControlsToggled = onControlsToggled ) @@ -264,6 +266,7 @@ private fun BoxScope.Viewport( overflowParticipants: List, scaffoldState: BottomSheetScaffoldState, callControlsState: CallControlsState, + callScreenState: CallScreenState, onPipClick: () -> Unit, onControlsToggled: (Boolean) -> Unit ) { @@ -279,7 +282,7 @@ private fun BoxScope.Viewport( val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT val scope = rememberCoroutineScope() - val hideSheet by rememberUpdatedState(newValue = scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded && !callControlsState.skipHiddenState) + val hideSheet by rememberUpdatedState(newValue = scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded && !callControlsState.skipHiddenState && !callScreenState.isDisplayingAudioToggleSheet) LaunchedEffect(hideSheet) { if (hideSheet) { delay(5.seconds) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt index 0ebbe12374..6dd01ee285 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt @@ -14,15 +14,18 @@ import kotlin.time.Duration.Companion.seconds * This contains higher level information that would have traditionally been directly * set on views. (Statuses, popups, etc.), allowing us to manage this from CallViewModel * - * @param status Status text resource to display as call status. + * @param callRecipientId The recipient ID of the call target (1:1 recipient, call link, or group) * @param hangup Set on call termination. * @param callControlsChange Update to display in a CallStateUpdate component. + * @param callStatus Status text resource to display as call status. + * @param isDisplayingAudioToggleSheet Whether the audio toggle sheet is currently displayed. Displaying this sheet should suppress hiding the controls. */ data class CallScreenState( val callRecipientId: RecipientId = RecipientId.UNKNOWN, val hangup: Hangup? = null, val callControlsChange: CallControlsChange? = null, - val callStatus: CallString? = null + val callStatus: CallString? = null, + val isDisplayingAudioToggleSheet: Boolean = false ) { data class Hangup( val hangupMessageType: HangupMessage.Type, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallViewModel.kt index 3a2279e842..3368f1c99e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallViewModel.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.components.webrtc.v2 +import android.os.Build import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -18,6 +19,8 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -26,6 +29,7 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.service.webrtc.SignalCallManager import org.thoughtcrime.securesms.sms.MessageSender +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager import org.whispersystems.signalservice.api.messages.calls.HangupMessage import kotlin.time.Duration.Companion.milliseconds @@ -311,4 +315,26 @@ class CallViewModel( else -> null } } + + fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) { + internalCallScreenState.update { + it.copy(isDisplayingAudioToggleSheet = displayed) + } + } + + fun onSelectedAudioDeviceChanged(audioDevice: WebRtcAudioDevice) { + // TODO [alex] maybeDisplaySpeakerphonePopup(audioOutput); + if (Build.VERSION.SDK_INT >= 31) { + AppDependencies.signalCallManager.selectAudioDevice(SignalAudioManager.ChosenAudioDeviceIdentifier(audioDevice.deviceId!!)) + } else { + val managerDevice = when (audioDevice.webRtcAudioOutput) { + WebRtcAudioOutput.HANDSET -> SignalAudioManager.AudioDevice.EARPIECE + WebRtcAudioOutput.SPEAKER -> SignalAudioManager.AudioDevice.SPEAKER_PHONE + WebRtcAudioOutput.BLUETOOTH_HEADSET -> SignalAudioManager.AudioDevice.BLUETOOTH + WebRtcAudioOutput.WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET + } + + AppDependencies.signalCallManager.selectAudioDevice(SignalAudioManager.ChosenAudioDeviceIdentifier(managerDevice)) + } + } } diff --git a/app/src/main/res/drawable/symbol_speaker_bluetooth_outline_24.xml b/app/src/main/res/drawable/symbol_speaker_bluetooth_outline_24.xml new file mode 100644 index 0000000000..55d39b110d --- /dev/null +++ b/app/src/main/res/drawable/symbol_speaker_bluetooth_outline_24.xml @@ -0,0 +1,12 @@ + + + +