From e8f1bf0085d4f7c7f5f2d75376ed836f6603337b Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 12 May 2026 19:33:02 +0200 Subject: [PATCH] Make Element Call screen work edge-to-edge (#6634) * Update dependency io.element.android:element-call-embedded to v0.19.3 * Remove `Scaffold` component from CallScreenView * Add immersive mode to calls in landscape orientation * Add `consumeWindowInsets`, which fixes the webview not displaying any insets for the bottom nav bar * Update screenshots * Ignore compact height in PiP mode --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: ElementBot --- .../features/call/impl/ui/CallScreenView.kt | 149 +++++++++--------- .../call/impl/ui/ElementCallActivity.kt | 25 +++ ...s.call.impl.ui_CallScreenView_Day_3_en.png | 4 +- ...call.impl.ui_CallScreenView_Night_3_en.png | 4 +- 4 files changed, 100 insertions(+), 82 deletions(-) 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 1c68a62f55..095d511e0e 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 @@ -17,9 +17,10 @@ import android.webkit.WebChromeClient import android.webkit.WebView import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,7 +46,6 @@ import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings import timber.log.Timber @@ -72,86 +72,79 @@ internal fun CallScreenView( } } - Scaffold( - modifier = modifier, - ) { padding -> - BackHandler { - handleBack() + BackHandler { + handleBack() + } + if (state.webViewError != null) { + ErrorDialog( + content = buildString { + append(stringResource(CommonStrings.error_unknown)) + state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) } + }, + onSubmit = { state.eventSink(CallScreenEvent.Hangup) }, + ) + } else { + var webViewAudioManager by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + var invalidAudioDeviceReason by remember { mutableStateOf(null) } + invalidAudioDeviceReason?.let { + InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) { + invalidAudioDeviceReason = null + } } - if (state.webViewError != null) { - ErrorDialog( - content = buildString { - append(stringResource(CommonStrings.error_unknown)) - state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) } - }, - onSubmit = { state.eventSink(CallScreenEvent.Hangup) }, - ) - } 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.consumeWindowInsets(WindowInsets.systemBars).fillMaxSize(), + url = state.urlState, + userAgent = state.userAgent, + onPermissionsRequest = { request -> + val androidPermissions = mapWebkitPermissions(request.resources) + val callback: RequestPermissionCallback = { request.grant(it) } + requestPermissions(androidPermissions.toTypedArray(), callback) + }, + onConsoleMessage = onConsoleMessage, + onCreateWebView = { webView -> + webView.addBackHandler(onBackPressed = ::handleBack) + val interceptor = WebViewWidgetMessageInterceptor( + webView = webView, + onUrlLoaded = { url -> + webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null) + 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(CallScreenEvent.OnWebViewError(it)) }, + ) + webViewAudioManager = WebViewAudioManager( + webView = webView, + coroutineScope = coroutineScope, + onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it }, + ) + state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor)) + val pipController = WebViewPipController(webView) + pipState.eventSink(PictureInPictureEvent.SetPipController(pipController)) + }, + onDestroyWebView = { + // Reset audio mode + webViewAudioManager?.onCallStopped() } - - CallWebView( - modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) - .fillMaxSize(), - url = state.urlState, - userAgent = state.userAgent, - onPermissionsRequest = { request -> - val androidPermissions = mapWebkitPermissions(request.resources) - val callback: RequestPermissionCallback = { request.grant(it) } - requestPermissions(androidPermissions.toTypedArray(), callback) - }, - onConsoleMessage = onConsoleMessage, - onCreateWebView = { webView -> - webView.addBackHandler(onBackPressed = ::handleBack) - val interceptor = WebViewWidgetMessageInterceptor( - webView = webView, - onUrlLoaded = { url -> - webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null) - 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(CallScreenEvent.OnWebViewError(it)) }, - ) - webViewAudioManager = WebViewAudioManager( - webView = webView, - coroutineScope = coroutineScope, - onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it }, - ) - state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor)) - val pipController = WebViewPipController(webView) - pipState.eventSink(PictureInPictureEvent.SetPipController(pipController)) - }, - onDestroyWebView = { - // Reset audio mode - webViewAudioManager?.onCallStopped() - } - ) - when (state.urlState) { - AsyncData.Uninitialized, - is AsyncData.Loading -> - ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait)) - is AsyncData.Failure -> { - Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}") - ErrorDialog( - content = state.urlState.error.message.orEmpty(), - onSubmit = { state.eventSink(CallScreenEvent.Hangup) }, - ) - } - is AsyncData.Success -> Unit + ) + when (state.urlState) { + AsyncData.Uninitialized, + is AsyncData.Loading -> + ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait)) + is AsyncData.Failure -> { + Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}") + ErrorDialog( + content = state.urlState.error.message.orEmpty(), + onSubmit = { state.eventSink(CallScreenEvent.Hangup) }, + ) } + is AsyncData.Success -> Unit } } } 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 367328ed10..26df7c160c 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 @@ -32,6 +32,9 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.core.app.PictureInPictureModeChangedInfo import androidx.core.content.IntentCompat import androidx.core.util.Consumer +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import dev.zacsweers.metro.Inject import io.element.android.compound.colors.SemanticColorsLightDark @@ -52,6 +55,7 @@ import io.element.android.libraries.audio.api.AudioFocusRequester import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.theme.ElementThemeApp +import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSize import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.preferences.api.store.AppPreferencesStore import timber.log.Timber @@ -111,6 +115,27 @@ class ElementCallActivity : val colors by remember(webViewTarget.value?.sessionId) { enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId) }.collectAsState(SemanticColorsLightDark.default) + + // When the height is compact, hide the system bars by default to maximize the space for the call, using immersive mode + val hasCompactHeight = hasCompactHeightWindowSize() + DisposableEffect(hasCompactHeight, pipState.isInPictureInPicture) { + if (hasCompactHeight && !pipState.isInPictureInPicture) { + val window = this@ElementCallActivity.window ?: return@DisposableEffect onDispose {} + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + val systemBarInsets = WindowInsetsCompat.Type.systemBars() + insetsController.hide(systemBarInsets) + + insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + onDispose { + insetsController.show(systemBarInsets) + insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + } + } else { + onDispose {} + } + } + ElementThemeApp( appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService, diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png index b5e7419267..b45afb67bf 100644 --- a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:48fa7b1415694f0a7ebb3de458edee8792f5643f681cc25627560f2e3d8ba491 -size 16331 +oid sha256:49731638f35e9c7583ece7122e3690728f3d4183a65e2e49cd270d02fb93ac70 +size 15950 diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png index 1c8ce2991d..14fd900451 100644 --- a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ebf559265b2cd4ebba7c5439b759365ed8d31532988bda5d8c76bb7c8d76dd68 -size 14892 +oid sha256:11a77776662896532ef2999e3b90aa93b3459bf9b7a5f461c06ccb3743612aab +size 14738