Disable BT audio devices for Element Call on Android < 12 (#4876)

Display an error dialog muting the call when a bluetooth audio device is selected on Android 11 or lower, re-enable the audio once another device is used.
This commit is contained in:
Jorge Martin Espinosa 2025-06-13 16:29:07 +02:00 committed by GitHub
parent 0b72bdc8ac
commit c598b0699e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 101 additions and 7 deletions

View file

@ -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)

View file

@ -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<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(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<String>,
@ -277,3 +305,9 @@ internal fun CallScreenPipViewPreview(
requestPermissions = { _, _ -> },
)
}
@PreviewsDayNight
@Composable
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
}

View file

@ -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<PowerManager>()
?.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<AudioDeviceInfo> = 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.
*/

View file

@ -3,5 +3,6 @@
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
<string name="call_foreground_service_message_android">"Tap to return to the call"</string>
<string name="call_foreground_service_title_android">"☎️ Call in progress"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call does not support using Bluetooth audio devices in this Android version. Please select a different audio device."</string>
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
</resources>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3c2a698cd7518bc96296bcd4f9b0b51b7a09094a178bca84b853efc03b58c228
size 25365

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3302dd631da6a38d569425e4a827051c00d20ab5755c7787739bf7ff37441dfd
size 23622

View file

@ -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",
]