Element Call: Add audio output selector handled by Android (#4663)

- Add onUrlLoaded callback to WebViewWidgetMessageInterceptor
- Add WebViewAudioManager component and use it instead of the AudioManager extension functions
- Enable controlling the audio devices in Element Call from the OS instead of automatically detecting them
- Simplify the window flags in ElementCallActivity
- Work around the issue where the default audio device wasn't using the right audio stream
- Add onAudioPlaybackStarted, use it to start the audio-device related logic
This commit is contained in:
Jorge Martin Espinosa
2025-06-09 09:12:53 +02:00
committed by GitHub
parent 31137fd20e
commit 55805bcfee
6 changed files with 489 additions and 122 deletions

View File

@@ -8,10 +8,6 @@
package io.element.android.features.call.impl.ui
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.util.Log
import android.view.ViewGroup
import android.webkit.ConsoleMessage
@@ -28,6 +24,7 @@ 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
@@ -35,17 +32,15 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.getSystemService
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.call.impl.R
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.WebViewAudioManager
import io.element.android.features.call.impl.utils.WebViewPipController
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
import io.element.android.libraries.androidutils.compat.disableExternalAudioDevice
import io.element.android.libraries.androidutils.compat.enableExternalAudioDevice
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -108,6 +103,8 @@ internal fun CallScreenView(
onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
)
} else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
CallWebView(
modifier = Modifier
.padding(padding)
@@ -120,14 +117,27 @@ internal fun CallScreenView(
val callback: RequestPermissionCallback = { request.grant(it) }
requestPermissions(androidPermissions.toTypedArray(), callback)
},
onWebViewCreate = { webView ->
onCreateWebView = { webView ->
val interceptor = WebViewWidgetMessageInterceptor(
webView = webView,
onUrlLoaded = { url ->
if (webViewAudioManager?.isInCallMode?.get() == false) {
Timber.d("URL $url is loaded, starting in-call audio mode")
webViewAudioManager?.onCallStarted()
} else {
Timber.d("Can't start in-call audio mode since the app is already in it.")
}
},
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
)
webViewAudioManager = WebViewAudioManager(webView, coroutineScope)
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
},
onDestroyWebView = {
// Reset audio mode
webViewAudioManager?.onCallStopped()
}
)
when (state.urlState) {
@@ -152,7 +162,8 @@ private fun CallWebView(
url: AsyncData<String>,
userAgent: String,
onPermissionsRequest: (PermissionRequest) -> Unit,
onWebViewCreate: (WebView) -> Unit,
onCreateWebView: (WebView) -> Unit,
onDestroyWebView: (WebView) -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
@@ -160,13 +171,11 @@ private fun CallWebView(
Text("WebView - can't be previewed")
}
} else {
var audioDeviceCallback: AudioDeviceCallback? by remember { mutableStateOf(null) }
AndroidView(
modifier = modifier,
factory = { context ->
audioDeviceCallback = context.setupAudioConfiguration()
WebView(context).apply {
onWebViewCreate(this)
onCreateWebView(this)
setup(userAgent, onPermissionsRequest)
}
},
@@ -176,41 +185,13 @@ private fun CallWebView(
}
},
onRelease = { webView ->
// Reset audio mode
webView.context.releaseAudioConfiguration(audioDeviceCallback)
onDestroyWebView(webView)
webView.destroy()
}
)
}
}
private fun Context.setupAudioConfiguration(): AudioDeviceCallback? {
val audioManager = getSystemService<AudioManager>() ?: return null
// Set 'voice call' mode so volume keys actually control the call volume
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
audioManager.enableExternalAudioDevice()
return object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
Timber.d("Audio devices added")
audioManager.enableExternalAudioDevice()
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
Timber.d("Audio devices removed")
audioManager.enableExternalAudioDevice()
}
}.also {
audioManager.registerAudioDeviceCallback(it, null)
}
}
private fun Context.releaseAudioConfiguration(audioDeviceCallback: AudioDeviceCallback?) {
val audioManager = getSystemService<AudioManager>() ?: return
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
audioManager.disableExternalAudioDevice()
audioManager.mode = AudioManager.MODE_NORMAL
}
@SuppressLint("SetJavaScriptEnabled")
private fun WebView.setup(
userAgent: String,

View File

@@ -81,13 +81,14 @@ class ElementCallActivity :
applicationContext.bindings<CallBindings>().inject(this)
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
}
setCallType(intent)
// If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early

View File

@@ -0,0 +1,450 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.PowerManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.core.content.getSystemService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* This class manages the audio devices for a WebView.
*
* It listens for audio device changes and updates the WebView with the available devices.
* It also handles the selection of the audio device by the user in the WebView and the default audio device based on the device type.
*
* See also: [Element Call controls docs.](https://github.com/element-hq/element-call/blob/livekit/docs/controls.md#audio-devices)
*/
class WebViewAudioManager(
private val webView: WebView,
private val coroutineScope: CoroutineScope,
) {
// 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,
// USB devices which can play or record audio
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_ACCESSORY,
// Wired audio devices
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
// The built-in speaker of the device
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
// The built-in earpiece of the device
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
)
private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val proximitySensorWakeLock by lazy {
webView.context.getSystemService<PowerManager>()
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) }
?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "${webView.context.packageName}:ProximitySensorCallWakeLock")
}
/**
* This listener tracks the current communication device and updates the WebView when it changes.
*/
private val commsDeviceChangedListener = AudioManager.OnCommunicationDeviceChangedListener { device ->
if (device != null && device.id == expectedNewCommunicationDeviceId) {
expectedNewCommunicationDeviceId = null
Timber.d("Audio device changed, type: ${device.type}")
updateSelectedAudioDeviceInWebView(device.id.toString())
} else if (device != null && device.id != expectedNewCommunicationDeviceId) {
// We were expecting a device change but it didn't happen, so we should retry
val expectedDeviceId = expectedNewCommunicationDeviceId
if (expectedDeviceId != null) {
// Remove the expected id so we only retry once
expectedNewCommunicationDeviceId = null
audioManager.selectAudioDevice(expectedDeviceId.toString())
}
} else {
Timber.d("Audio device cleared")
expectedNewCommunicationDeviceId = null
audioManager.selectAudioDevice(null)
}
}
/**
* This callback is used to listen for audio device changes coming from the OS.
*/
private val audioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
val validNewDevices = addedDevices.orEmpty().filter { it.type in wantedDeviceTypes && it.isSink }
if (validNewDevices.isEmpty()) return
// We need to calculate the available devices ourselves, since calling `listAudioDevices` will return an outdated list
val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id }
setAvailableAudioDevices(audioDevices.map(SerializableAudioDevice::fromAudioDeviceInfo))
// This should automatically switch to a new device if it has a higher priority than the current one
selectDefaultAudioDevice(audioDevices)
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
// Update the available devices
setAvailableAudioDevices()
// Unless the removed device is the current one, we don't need to do anything else
val removedCurrentDevice = removedDevices.orEmpty().any { it.id == currentDeviceId }
if (!removedCurrentDevice) return
val previousDevice = previousSelectedDevice
if (previousDevice != null) {
previousSelectedDevice = null
// If we have a previous device, we should select it again
audioManager.selectAudioDevice(previousDevice.id.toString())
} else {
// If we don't have a previous device, we should select the default one
selectDefaultAudioDevice()
}
}
}
/**
* The currently used audio device id.
*/
private var currentDeviceId: Int? = null
/**
* When a new audio device is selected but not yet set as the communication device by the OS, this id is used to check if the device is the expected one.
*/
private var expectedNewCommunicationDeviceId: Int? = null
/**
* Previously selected device, used to restore the selection when the selected device is removed.
*/
private var previousSelectedDevice: AudioDeviceInfo? = null
private var hasRegisteredCallbacks = false
/**
* Marks if the WebView audio is in call mode or not.
*/
val isInCallMode = AtomicBoolean(false)
init {
// Apparently, registering the javascript interface takes a while, so registering and immediately using it doesn't work
// We register it ahead of time to avoid this issue
registerWebViewDeviceSelectedCallback()
}
/**
* Call this method when the call starts to enable in-call audio mode.
*
* It'll set the audio mode to [AudioManager.MODE_IN_COMMUNICATION] if possible, register the audio device callback and set the available audio devices.
*/
fun onCallStarted() {
if (!isInCallMode.compareAndSet(false, true)) {
Timber.w("Audio: tried to enable webview in-call audio mode while already in it")
return
}
Timber.d("Audio: enabling webview in-call audio mode")
audioManager.mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Set 'voice call' mode so volume keys actually control the call volume
AudioManager.MODE_IN_COMMUNICATION
} else {
// Workaround for Android 12 and lower, otherwise changing the audio device doesn't work
AudioManager.MODE_NORMAL
}
setWebViewAndroidNativeBridge()
}
/**
* Call this method when the call stops to disable in-call audio mode.
*
* It's the counterpart of [onCallStarted], and should be called as a pair with it once the call has ended.
*/
fun onCallStopped() {
if (!isInCallMode.compareAndSet(true, false)) {
Timber.w("Audio: tried to disable webview in-call audio mode while already disabled")
return
}
if (proximitySensorWakeLock?.isHeld == true) {
proximitySensorWakeLock?.release()
}
audioManager.mode = AudioManager.MODE_NORMAL
if (!hasRegisteredCallbacks) {
Timber.w("Audio: tried to disable webview in-call audio mode without registering callbacks")
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
audioManager.removeOnCommunicationDeviceChangedListener(commsDeviceChangedListener)
}
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
}
/**
* Registers the WebView audio device selected callback.
*
* This should be called when the WebView is created to ensure that the callback is set before any audio device selection is made.
*/
private fun registerWebViewDeviceSelectedCallback() {
val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge(
onAudioDeviceSelected = { selectedDeviceId ->
Timber.d("Audio device selected in webview, id: $selectedDeviceId")
previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId }
audioManager.selectAudioDevice(selectedDeviceId)
},
onAudioPlaybackStarted = {
coroutineScope.launch(Dispatchers.Main) {
// Calling this ahead of time makes the default audio device to not use the right audio stream
setAvailableAudioDevices()
// Registering the audio devices changed callback will also set the default audio device
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.addOnCommunicationDeviceChangedListener(Executors.newSingleThreadExecutor(), commsDeviceChangedListener)
}
hasRegisteredCallbacks = true
}
}
)
Timber.d("Setting androidNativeBridge javascript interface in webview")
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
}
/**
* Assigns the callback in the WebView to be called when the user selects an audio device.
*
* It should be called with some delay after [registerWebViewDeviceSelectedCallback].
*/
private fun setWebViewAndroidNativeBridge() {
Timber.d("Adding callback in controls.onAudioPlaybackStarted")
webView.evaluateJavascript("controls.onAudioPlaybackStarted = () => { androidNativeBridge.onTrackReady(); };", null)
Timber.d("Adding callback in controls.onOutputDeviceSelect")
webView.evaluateJavascript("controls.onOutputDeviceSelect = (id) => { androidNativeBridge.setOutputDevice(id); };", null)
}
/**
* Returns the list of available audio devices.
*
* On Android 11 ([Build.VERSION_CODES.R]) and lower, it returns the list of output devices as a fallback.
*/
private fun listAudioDevices(): List<AudioDeviceInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.availableCommunicationDevices
} else {
val rawAudioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
rawAudioDevices.filter { it.type in wantedDeviceTypes && it.isSink }
}
}
/**
* Sets the available audio devices in the WebView.
*
* @param devices The list of audio devices to set. If not provided, it will use the current list of audio devices.
*/
private fun setAvailableAudioDevices(
devices: List<SerializableAudioDevice> = listAudioDevices().map(SerializableAudioDevice::fromAudioDeviceInfo),
) {
Timber.d("Updating available audio devices")
val jsonSerializer = Json {
encodeDefaults = true
explicitNulls = false
}
val deviceList = jsonSerializer.encodeToString(devices)
webView.evaluateJavascript("controls.setAvailableOutputDevices($deviceList);", {
Timber.d("Audio: setAvailableOutputDevices result: $it")
})
}
/**
* Selects the default audio device based on the available 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()) {
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)
selectedDevice?.let {
updateSelectedAudioDeviceInWebView(it.id.toString())
} ?: run {
Timber.w("Audio: unable to select default audio device")
}
}
/**
* Updates the WebView's UI to reflect the selected audio device.
*
* @param deviceId The id of the selected audio device.
*/
private fun updateSelectedAudioDeviceInWebView(deviceId: String) {
coroutineScope.launch(Dispatchers.Main) {
webView.evaluateJavascript("controls.setOutputDevice('$deviceId');", null)
}
}
/**
* Selects the audio device on the OS based on the provided device id.
*
* It will select the device only if it is available in the list of audio devices.
*
* @param device The id of the audio device to select.
*/
private fun AudioManager.selectAudioDevice(device: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val audioDevice = availableCommunicationDevices.find { it.id.toString() == device }
selectAudioDevice(audioDevice)
} else {
val rawAudioDevices = getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val audioDevice = rawAudioDevices.find { it.id.toString() == device }
selectAudioDevice(audioDevice)
}
}
/**
* Selects the audio device on the OS based on the provided device info.
*
* @param device The info of the audio device to select, or none to clear the selected device.
*/
@Suppress("DEPRECATION")
private fun AudioManager.selectAudioDevice(device: AudioDeviceInfo?) {
currentDeviceId = device?.id
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (device != null) {
Timber.d("Setting communication device: ${device.id} - ${deviceName(device.type, device.productName.toString())}")
setCommunicationDevice(device)
} else {
audioManager.clearCommunicationDevice()
}
} else {
// 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) {
isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
} else {
isSpeakerphoneOn = false
isBluetoothScoOn = false
}
}
expectedNewCommunicationDeviceId = null
@Suppress("WakeLock", "WakeLockTimeout")
if (device?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE && proximitySensorWakeLock?.isHeld == false) {
// If the device is the built-in earpiece, we need to acquire the proximity sensor wake lock
proximitySensorWakeLock?.acquire()
} else if (proximitySensorWakeLock?.isHeld == true) {
// If the device is no longer the earpiece, we need to release the wake lock
proximitySensorWakeLock?.release()
}
}
}
/**
* This class is used to handle the audio device selection in the WebView.
* It listens for the audio device selection event and calls the callback with the selected device ID.
*/
private class AndroidWebViewAudioBridge(
private val onAudioDeviceSelected: (String) -> Unit,
private val onAudioPlaybackStarted: () -> Unit,
) {
@JavascriptInterface
fun setOutputDevice(id: String) {
Timber.d("Audio device selected in webview, id: $id")
onAudioDeviceSelected(id)
}
@JavascriptInterface
fun onTrackReady() {
// This method can be used to notify the WebView that the audio track is ready
// It can be used to start playing audio or to update the UI
Timber.d("Audio track is ready")
onAudioPlaybackStarted()
}
}
private fun deviceName(type: Int, name: String): String {
// TODO maybe translate these?
val typePart = when (type) {
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth"
AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB accessory"
AudioDeviceInfo.TYPE_USB_DEVICE -> "USB device"
AudioDeviceInfo.TYPE_USB_HEADSET -> "USB headset"
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired headset"
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Wired headphones"
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Built-in speaker"
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Built-in earpiece"
else -> "Unknown"
}
return if (isBuiltIn(type)) {
typePart
} else {
"$typePart - $name"
}
}
private fun isBuiltIn(type: Int): Boolean = when (type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
AudioDeviceInfo.TYPE_BUILTIN_MIC,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> true
else -> false
}
/**
* This class is used to serialize the audio device information to JSON.
*/
@Suppress("unused")
@Serializable
internal data class SerializableAudioDevice(
val id: String,
val name: String,
@Transient val type: Int = 0,
// These have to be part of the constructor for the JSON serializer to pick them up
val isEarpiece: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
val isSpeaker: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
val isExternalHeadset: Boolean = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
) {
companion object {
fun fromAudioDeviceInfo(audioDeviceInfo: AudioDeviceInfo): SerializableAudioDevice {
return SerializableAudioDevice(
id = audioDeviceInfo.id.toString(),
name = deviceName(type = audioDeviceInfo.type, name = audioDeviceInfo.productName.toString()),
type = audioDeviceInfo.type,
)
}
}
}

View File

@@ -26,6 +26,7 @@ import timber.log.Timber
class WebViewWidgetMessageInterceptor(
private val webView: WebView,
private val onUrlLoaded: (String) -> Unit,
private val onError: (String?) -> Unit,
) : WidgetMessageInterceptor {
companion object {
@@ -44,13 +45,13 @@ class WebViewWidgetMessageInterceptor(
.build()
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
// Due to https://github.com/element-hq/element-x-android/issues/4097
// we need to supply a logging implementation that correctly includes
// objects in log lines.
view?.evaluateJavascript(
view.evaluateJavascript(
"""
function logFn(consoleLogFn, ...args) {
consoleLogFn(
@@ -72,7 +73,7 @@ class WebViewWidgetMessageInterceptor(
// This listener will receive both messages:
// - EC widget API -> Element X (message.data.api == "fromWidget")
// - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
view?.evaluateJavascript(
view.evaluateJavascript(
"""
window.addEventListener('message', function(event) {
let message = {data: event.data, origin: event.origin}
@@ -90,6 +91,10 @@ class WebViewWidgetMessageInterceptor(
)
}
override fun onPageFinished(view: WebView, url: String) {
onUrlLoaded(url)
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
// No network for instance, transmit the error
Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}")

View File

@@ -1,70 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.compat
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import io.element.android.libraries.core.data.tryOrNull
import timber.log.Timber
fun AudioManager.enableExternalAudioDevice() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication.
val wantedDeviceTypes = listOf(
// Paired bluetooth device with microphone
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
// USB devices which can play or record audio
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_ACCESSORY,
// Wired audio devices
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
// The built-in speaker of the device
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
// The built-in earpiece of the device
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
)
val devices = availableCommunicationDevices
val selectedDevice = devices.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
}
}
selectedDevice?.let { device ->
Timber.d("Audio device selected, type: ${device.type}")
tryOrNull(
onException = { failure ->
Timber.e(failure, "Audio: exception when setting communication device")
}
) {
setCommunicationDevice(device).also {
if (!it) {
Timber.w("Audio: unable to set the communication device")
}
}
}
}
} else {
// If we don't have access to the new APIs, use the deprecated ones
@Suppress("DEPRECATION")
isSpeakerphoneOn = true
}
}
fun AudioManager.disableExternalAudioDevice() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
clearCommunicationDevice()
} else {
// If we don't have access to the new APIs, use the deprecated ones
@Suppress("DEPRECATION")
isSpeakerphoneOn = false
}
}

View File

@@ -49,7 +49,7 @@ class DefaultCallWidgetSettingsProvider @Inject constructor(
sentryEnvironment = if (buildMeta.buildType == BuildType.RELEASE) "RELEASE" else "DEBUG",
parentUrl = null,
hideHeader = true,
controlledMediaDevices = false,
controlledMediaDevices = true,
)
val rustWidgetSettings = newVirtualElementCallWidget(options)
return MatrixWidgetSettings.fromRustWidgetSettings(rustWidgetSettings)