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:
parent
cc051a7e8e
commit
299f996da1
4 changed files with 100 additions and 82 deletions
|
|
@ -17,9 +17,10 @@ import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -72,86 +72,79 @@ internal fun CallScreenView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
BackHandler {
|
||||||
modifier = modifier,
|
handleBack()
|
||||||
) { padding ->
|
}
|
||||||
BackHandler {
|
if (state.webViewError != null) {
|
||||||
handleBack()
|
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) }
|
CallWebView(
|
||||||
invalidAudioDeviceReason?.let {
|
modifier = modifier.consumeWindowInsets(WindowInsets.systemBars).fillMaxSize(),
|
||||||
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
|
url = state.urlState,
|
||||||
invalidAudioDeviceReason = null
|
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(
|
when (state.urlState) {
|
||||||
modifier = Modifier
|
AsyncData.Uninitialized,
|
||||||
.padding(padding)
|
is AsyncData.Loading ->
|
||||||
.consumeWindowInsets(padding)
|
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
|
||||||
.fillMaxSize(),
|
is AsyncData.Failure -> {
|
||||||
url = state.urlState,
|
Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
|
||||||
userAgent = state.userAgent,
|
ErrorDialog(
|
||||||
onPermissionsRequest = { request ->
|
content = state.urlState.error.message.orEmpty(),
|
||||||
val androidPermissions = mapWebkitPermissions(request.resources)
|
onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
is AsyncData.Success -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||||
import androidx.core.content.IntentCompat
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.util.Consumer
|
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 androidx.lifecycle.Lifecycle
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
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.log.logger.LoggerTag
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
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.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -111,6 +115,27 @@ class ElementCallActivity :
|
||||||
val colors by remember(webViewTarget.value?.sessionId) {
|
val colors by remember(webViewTarget.value?.sessionId) {
|
||||||
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
|
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
|
||||||
}.collectAsState(SemanticColorsLightDark.default)
|
}.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(
|
ElementThemeApp(
|
||||||
appPreferencesStore = appPreferencesStore,
|
appPreferencesStore = appPreferencesStore,
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:48fa7b1415694f0a7ebb3de458edee8792f5643f681cc25627560f2e3d8ba491
|
oid sha256:49731638f35e9c7583ece7122e3690728f3d4183a65e2e49cd270d02fb93ac70
|
||||||
size 16331
|
size 15950
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ebf559265b2cd4ebba7c5439b759365ed8d31532988bda5d8c76bb7c8d76dd68
|
oid sha256:11a77776662896532ef2999e3b90aa93b3459bf9b7a5f461c06ccb3743612aab
|
||||||
size 14892
|
size 14738
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue