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 25e856444c1..022e3664a7e 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 @@ -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 @@ -35,17 +31,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 +102,7 @@ internal fun CallScreenView( onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, ) } else { + var webViewAudioManager by remember { mutableStateOf(null) } CallWebView( modifier = Modifier .padding(padding) @@ -120,14 +115,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) state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) val pipController = WebViewPipController(webView) pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) + }, + onDestroyWebView = { + // Reset audio mode + webViewAudioManager?.onCallStopped() } ) when (state.urlState) { @@ -150,7 +158,8 @@ private fun CallWebView( url: AsyncData, userAgent: String, onPermissionsRequest: (PermissionRequest) -> Unit, - onWebViewCreate: (WebView) -> Unit, + onCreateWebView: (WebView) -> Unit, + onDestroyWebView: (WebView) -> Unit, modifier: Modifier = Modifier, ) { if (LocalInspectionMode.current) { @@ -158,13 +167,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) } }, @@ -174,41 +181,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() ?: 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?) { - Timber.d("Audio devices added") - audioManager.enableExternalAudioDevice() - } - - override fun onAudioDevicesRemoved(removedDevices: Array?) { - Timber.d("Audio devices removed") - audioManager.enableExternalAudioDevice() - } - }.also { - audioManager.registerAudioDeviceCallback(it, null) - } -} - -private fun Context.releaseAudioConfiguration(audioDeviceCallback: AudioDeviceCallback?) { - val audioManager = getSystemService() ?: return - audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) - audioManager.disableExternalAudioDevice() - audioManager.mode = AudioManager.MODE_NORMAL -} - @SuppressLint("SetJavaScriptEnabled") private fun WebView.setup( userAgent: String, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt index 4c44fb29d96..74e50cd8cd0 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt @@ -81,13 +81,14 @@ class ElementCallActivity : applicationContext.bindings().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 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 new file mode 100644 index 00000000000..9ff0ce148db --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt @@ -0,0 +1,413 @@ +/* + * 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.MainScope +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. + */ +class WebViewAudioManager( + private val webView: WebView, +) { + // 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() + ?.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?.id == expectedNewCommunicationDeviceId) { + if (device != null) { + expectedNewCommunicationDeviceId = null + Timber.d("Audio device changed, type: ${device.type}") + updateSelectedAudioDeviceInWebView(device.id.toString()) + } else { + Timber.d("No audio device selected") + } + } else { + // 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()) + } + } + } + + /** + * This callback is used to listen for audio device changes coming from the OS. + */ + private val audioDeviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array?) { + 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?) { + // 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 + + /** + * 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") + + // TODO double check used audio stream + 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 + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.addOnCommunicationDeviceChangedListener(Executors.newSingleThreadExecutor(), commsDeviceChangedListener) + } + + audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) + + setAvailableAudioDevices() + selectDefaultAudioDevice() + setWebViewOnAudioDeviceSelectedCallback() + } + + /** + * 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 + } + audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.removeOnCommunicationDeviceChangedListener(commsDeviceChangedListener) + } + + if (proximitySensorWakeLock?.isHeld == true) { + proximitySensorWakeLock?.release() + } + + audioManager.mode = AudioManager.MODE_NORMAL + } + + /** + * 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 = WebViewAudioOutputCallback { selectedDeviceId -> + Timber.d("Audio device selected in webview, id: $selectedDeviceId") + previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId } + audioManager.selectAudioDevice(selectedDeviceId) + } + Timber.d("Setting onAudioDeviceSelectedCallback javascript interface in webview") + webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "onAudioDeviceSelectedCallback") + } + + /** + * 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 setWebViewOnAudioDeviceSelectedCallback() { + Timber.d("Adding callback in controls.onOutputDeviceSelect") + webView.evaluateJavascript("controls.onOutputDeviceSelect = (id) => { onAudioDeviceSelectedCallback.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 { + 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 = 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 = 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) { + MainScope().launch { 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) { + if (device != communicationDevice) { + 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 + } + } + + @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 WebViewAudioOutputCallback( + private val callback: (String) -> Unit, +) { + @JavascriptInterface + fun setOutputDevice(id: String) { + Timber.d("Audio device selected in webview, id: $id") + callback(id) + } +} + +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, + ) + } + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt index 8fc5d8b7d62..55adc246e9e 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt @@ -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}") diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/AudioManager.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/AudioManager.kt deleted file mode 100644 index 5e77d0d00a4..00000000000 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/AudioManager.kt +++ /dev/null @@ -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( - onError = { 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 - } -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt index 77152776aa4..eed81c2dcbd 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt @@ -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)