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 <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2026-05-12 19:33:02 +02:00 committed by GitHub
parent 9eac27515e
commit e8f1bf0085
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 100 additions and 82 deletions

View file

@ -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<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(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<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(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
}
}
}

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:48fa7b1415694f0a7ebb3de458edee8792f5643f681cc25627560f2e3d8ba491
size 16331
oid sha256:49731638f35e9c7583ece7122e3690728f3d4183a65e2e49cd270d02fb93ac70
size 15950

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ebf559265b2cd4ebba7c5439b759365ed8d31532988bda5d8c76bb7c8d76dd68
size 14892
oid sha256:11a77776662896532ef2999e3b90aa93b3459bf9b7a5f461c06ccb3743612aab
size 14738