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:
parent
1a8f38e29a
commit
024a6b4f90
7 changed files with 101 additions and 7 deletions
|
|
@ -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.appnavstate.api.AppForegroundStateService
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -52,6 +53,7 @@ import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class CallScreenPresenter @AssistedInject constructor(
|
class CallScreenPresenter @AssistedInject constructor(
|
||||||
@Assisted private val callType: CallType,
|
@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.
|
// If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
|
||||||
sendHangupMessage(widgetId, interceptor)
|
sendHangupMessage(widgetId, interceptor)
|
||||||
isJoinedCall = false
|
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 {
|
} else {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
close(callWidgetDriver.value, navigator)
|
close(callWidgetDriver.value, navigator)
|
||||||
|
|
|
||||||
|
|
@ -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.PictureInPictureState
|
||||||
import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
|
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.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.WebViewAudioManager
|
||||||
import io.element.android.features.call.impl.utils.WebViewPipController
|
import io.element.android.features.call.impl.utils.WebViewPipController
|
||||||
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
|
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
|
||||||
|
|
@ -105,6 +106,14 @@ internal fun CallScreenView(
|
||||||
} else {
|
} else {
|
||||||
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
|
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(null) }
|
||||||
|
invalidAudioDeviceReason?.let {
|
||||||
|
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
|
||||||
|
invalidAudioDeviceReason = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CallWebView(
|
CallWebView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
|
|
@ -130,7 +139,11 @@ internal fun CallScreenView(
|
||||||
},
|
},
|
||||||
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
|
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
|
||||||
)
|
)
|
||||||
webViewAudioManager = WebViewAudioManager(webView, coroutineScope)
|
webViewAudioManager = WebViewAudioManager(
|
||||||
|
webView = webView,
|
||||||
|
coroutineScope = coroutineScope,
|
||||||
|
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
|
||||||
|
)
|
||||||
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
|
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
|
||||||
val pipController = WebViewPipController(webView)
|
val pipController = WebViewPipController(webView)
|
||||||
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
|
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
|
@Composable
|
||||||
private fun CallWebView(
|
private fun CallWebView(
|
||||||
url: AsyncData<String>,
|
url: AsyncData<String>,
|
||||||
|
|
@ -277,3 +305,9 @@ internal fun CallScreenPipViewPreview(
|
||||||
requestPermissions = { _, _ -> },
|
requestPermissions = { _, _ -> },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
|
||||||
|
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,22 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||||
class WebViewAudioManager(
|
class WebViewAudioManager(
|
||||||
private val webView: WebView,
|
private val webView: WebView,
|
||||||
private val coroutineScope: CoroutineScope,
|
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(
|
private val wantedDeviceTypes = listOf(
|
||||||
// Paired bluetooth device with microphone
|
// Paired bluetooth device with microphone
|
||||||
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
|
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
|
||||||
|
|
@ -60,6 +74,10 @@ class WebViewAudioManager(
|
||||||
|
|
||||||
private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
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 {
|
private val proximitySensorWakeLock by lazy {
|
||||||
webView.context.getSystemService<PowerManager>()
|
webView.context.getSystemService<PowerManager>()
|
||||||
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) }
|
?.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.
|
* @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()) {
|
private fun selectDefaultAudioDevice(availableDevices: List<AudioDeviceInfo> = listAudioDevices()) {
|
||||||
val selectedDevice = availableDevices.minByOrNull {
|
val selectedDevice = availableDevices
|
||||||
wantedDeviceTypes.indexOf(it.type).let { index ->
|
.minByOrNull {
|
||||||
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
|
wantedDeviceTypes.indexOf(it.type).let { index ->
|
||||||
if (index == -1) Int.MAX_VALUE else 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
|
expectedNewCommunicationDeviceId = selectedDevice?.id
|
||||||
audioManager.selectAudioDevice(selectedDevice)
|
audioManager.selectAudioDevice(selectedDevice)
|
||||||
|
|
@ -361,6 +380,13 @@ class WebViewAudioManager(
|
||||||
// On Android 11 and lower, we don't have the concept of communication devices
|
// 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
|
// We have to call the right methods based on the device type
|
||||||
if (device != null) {
|
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
|
isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
|
||||||
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
|
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -380,6 +406,19 @@ class WebViewAudioManager(
|
||||||
proximitySensorWakeLock?.release()
|
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
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class InvalidAudioDeviceReason {
|
||||||
|
BT_AUDIO_DEVICE_DISABLED,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is used to serialize the audio device information to JSON.
|
* This class is used to serialize the audio device information to JSON.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,6 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
|
<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_message_android">"Tap to return to the call"</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Call in progress"</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>
|
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3c2a698cd7518bc96296bcd4f9b0b51b7a09094a178bca84b853efc03b58c228
|
||||||
|
size 25365
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3302dd631da6a38d569425e4a827051c00d20ab5755c7787739bf7ff37441dfd
|
||||||
|
size 23622
|
||||||
|
|
@ -23,6 +23,7 @@ forbiddenTerms = {
|
||||||
"screen_onboarding_welcome_title",
|
"screen_onboarding_welcome_title",
|
||||||
# Contains "Element Call"
|
# Contains "Element Call"
|
||||||
"screen_incoming_call_subtitle_android",
|
"screen_incoming_call_subtitle_android",
|
||||||
|
"call_invalid_audio_device_bluetooth_devices_disabled",
|
||||||
# Contains "Element X"
|
# Contains "Element X"
|
||||||
"screen_room_timeline_legacy_call",
|
"screen_room_timeline_legacy_call",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue