diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt index 363ace4b79..e16428de3b 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -44,6 +44,7 @@ import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -52,6 +53,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import timber.log.Timber import java.util.UUID +import kotlin.time.Duration.Companion.seconds class CallScreenPresenter @AssistedInject constructor( @Assisted private val callType: CallType, @@ -165,6 +167,13 @@ class CallScreenPresenter @AssistedInject constructor( // If the call was joined, we need to hang up first. Then the UI will be dismissed automatically. sendHangupMessage(widgetId, interceptor) isJoinedCall = false + + coroutineScope.launch { + // Wait for a couple of seconds to receive the hangup message + // If we don't get it in time, we close the screen anyway + delay(2.seconds) + close(callWidgetDriver.value, navigator) + } } else { coroutineScope.launch { close(callWidgetDriver.value, navigator) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt index f1aa192d28..7547d8e7c7 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt @@ -38,6 +38,7 @@ import io.element.android.features.call.impl.pip.PictureInPictureEvents import io.element.android.features.call.impl.pip.PictureInPictureState import io.element.android.features.call.impl.pip.PictureInPictureStateProvider import io.element.android.features.call.impl.pip.aPictureInPictureState +import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason import io.element.android.features.call.impl.utils.WebViewAudioManager import io.element.android.features.call.impl.utils.WebViewPipController import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor @@ -105,6 +106,14 @@ internal fun CallScreenView( } else { var webViewAudioManager by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() + + var invalidAudioDeviceReason by remember { mutableStateOf(null) } + invalidAudioDeviceReason?.let { + InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) { + invalidAudioDeviceReason = null + } + } + CallWebView( modifier = Modifier .padding(padding) @@ -130,7 +139,11 @@ internal fun CallScreenView( }, onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) }, ) - webViewAudioManager = WebViewAudioManager(webView, coroutineScope) + webViewAudioManager = WebViewAudioManager( + webView = webView, + coroutineScope = coroutineScope, + onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it }, + ) state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) val pipController = WebViewPipController(webView) pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) @@ -157,6 +170,21 @@ internal fun CallScreenView( } } +@Composable +private fun InvalidAudioDeviceDialog( + invalidAudioDeviceReason: InvalidAudioDeviceReason, + onDismiss: () -> Unit, +) { + ErrorDialog( + content = when (invalidAudioDeviceReason) { + InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED -> { + stringResource(R.string.call_invalid_audio_device_bluetooth_devices_disabled) + } + }, + onSubmit = onDismiss, + ) +} + @Composable private fun CallWebView( url: AsyncData, @@ -277,3 +305,9 @@ internal fun CallScreenPipViewPreview( requestPermissions = { _, _ -> }, ) } + +@PreviewsDayNight +@Composable +internal fun InvalidAudioDeviceDialogPreview() = ElementPreview { + InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {} +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt index dc55ff2ac4..b7d1fdf466 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt @@ -40,8 +40,22 @@ import kotlin.time.Duration.Companion.milliseconds class WebViewAudioManager( private val webView: WebView, private val coroutineScope: CoroutineScope, + private val onInvalidAudioDeviceAdded: (InvalidAudioDeviceReason) -> Unit, ) { - // The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication. + /** + * Whether to disable bluetooth audio devices. This must be done on Android versions lower than Android 12, + * since the WebView approach breaks when using the legacy Bluetooth audio APIs. + */ + private val disableBluetoothAudioDevices = Build.VERSION.SDK_INT < Build.VERSION_CODES.S + + /** + * This flag indicates whether the WebView audio is enabled or not. By default, it is enabled. + */ + private val isWebViewAudioEnabled = AtomicBoolean(true) + + /** + * The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication. + */ private val wantedDeviceTypes = listOf( // Paired bluetooth device with microphone AudioDeviceInfo.TYPE_BLUETOOTH_SCO, @@ -60,6 +74,10 @@ class WebViewAudioManager( private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + /** + * This wake lock is used to turn off the screen when the proximity sensor is triggered during a call, + * if the selected audio device is the built-in earpiece. + */ private val proximitySensorWakeLock by lazy { webView.context.getSystemService() ?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) } @@ -296,12 +314,13 @@ class WebViewAudioManager( * @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices. */ private fun selectDefaultAudioDevice(availableDevices: List = listAudioDevices()) { - val selectedDevice = availableDevices.minByOrNull { - wantedDeviceTypes.indexOf(it.type).let { index -> - // If the device type is not in the wantedDeviceTypes list, we give it a low priority - if (index == -1) Int.MAX_VALUE else index + val selectedDevice = availableDevices + .minByOrNull { + wantedDeviceTypes.indexOf(it.type).let { index -> + // If the device type is not in the wantedDeviceTypes list, we give it a low priority + if (index == -1) Int.MAX_VALUE else index + } } - } expectedNewCommunicationDeviceId = selectedDevice?.id audioManager.selectAudioDevice(selectedDevice) @@ -361,6 +380,13 @@ class WebViewAudioManager( // On Android 11 and lower, we don't have the concept of communication devices // We have to call the right methods based on the device type if (device != null) { + if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO && disableBluetoothAudioDevices) { + Timber.w("Bluetooth audio devices are disabled on this Android version") + setAudioEnabled(false) + onInvalidAudioDeviceAdded(InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) + return + } + setAudioEnabled(true) isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } else { @@ -380,6 +406,19 @@ class WebViewAudioManager( proximitySensorWakeLock?.release() } } + + /** + * Sets whether the audio is enabled for Element Call in the WebView. + * It will only perform the change if the audio state has changed. + */ + private fun setAudioEnabled(enabled: Boolean) { + coroutineScope.launch(Dispatchers.Main) { + Timber.d("Setting audio enabled in Element Call: $enabled") + if (isWebViewAudioEnabled.getAndSet(enabled) != enabled) { + webView.evaluateJavascript("controls.setAudioEnabled($enabled);", null) + } + } + } } /** @@ -434,6 +473,10 @@ private fun isBuiltIn(type: Int): Boolean = when (type) { else -> false } +enum class InvalidAudioDeviceReason { + BT_AUDIO_DEVICE_DISABLED, +} + /** * This class is used to serialize the audio device information to JSON. */ diff --git a/features/call/impl/src/main/res/values/localazy.xml b/features/call/impl/src/main/res/values/localazy.xml index 5a386b2416..5ab2caa4fd 100644 --- a/features/call/impl/src/main/res/values/localazy.xml +++ b/features/call/impl/src/main/res/values/localazy.xml @@ -3,5 +3,6 @@ "Ongoing call" "Tap to return to the call" "☎️ Call in progress" + "Element Call does not support using Bluetooth audio devices in this Android version. Please select a different audio device." "Incoming Element Call" diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_InvalidAudioDeviceDialog_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_InvalidAudioDeviceDialog_Day_0_en.png new file mode 100644 index 0000000000..d7e0c73390 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_InvalidAudioDeviceDialog_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c2a698cd7518bc96296bcd4f9b0b51b7a09094a178bca84b853efc03b58c228 +size 25365 diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_InvalidAudioDeviceDialog_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_InvalidAudioDeviceDialog_Night_0_en.png new file mode 100644 index 0000000000..d5d6f55e1d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_InvalidAudioDeviceDialog_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3302dd631da6a38d569425e4a827051c00d20ab5755c7787739bf7ff37441dfd +size 23622 diff --git a/tools/localazy/checkForbiddenTerms.py b/tools/localazy/checkForbiddenTerms.py index 5d04743529..2ebe582ba3 100755 --- a/tools/localazy/checkForbiddenTerms.py +++ b/tools/localazy/checkForbiddenTerms.py @@ -23,6 +23,7 @@ forbiddenTerms = { "screen_onboarding_welcome_title", # Contains "Element Call" "screen_incoming_call_subtitle_android", + "call_invalid_audio_device_bluetooth_devices_disabled", # Contains "Element X" "screen_room_timeline_legacy_call", ]