Back button web view to esc (revive fixed version of: https://github.com/element-hq/element-x-android/pull/6724) (#6725)

* Change native back button behavior in EC view:
 - inject escape into webview instead of going back.
 - the webview will call back when no other modal is open.

* call down and up in the webview + make sure that we fall back to close
pip in case the webview did not handle the esc action.

* Tests and refactor to CallScreenBackPressPolicy

---------

Co-authored-by: Jorge Martín <jorgem@element.io>
This commit is contained in:
Timo 2026-05-13 16:17:23 +08:00 committed by GitHub
parent 2ea23bcc3e
commit c959f50d53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 317 additions and 11 deletions

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations 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.ui
internal sealed interface CallScreenBackPressAction {
data object DispatchEscapeToWebView : CallScreenBackPressAction
data object EnterPictureInPicture : CallScreenBackPressAction
}
internal object CallScreenBackPressPolicy {
fun resolve(
supportPip: Boolean,
hasWebView: Boolean,
fromNative: Boolean,
): CallScreenBackPressAction? {
return when {
hasWebView && fromNative -> CallScreenBackPressAction.DispatchEscapeToWebView
hasWebView && supportPip -> CallScreenBackPressAction.EnterPictureInPicture
else -> null
}
}
}

View file

@ -64,16 +64,20 @@ internal fun CallScreenView(
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
modifier: Modifier = Modifier,
) {
fun handleBack() {
if (pipState.supportPip) {
pipState.eventSink.invoke(PictureInPictureEvent.EnterPictureInPicture)
} else {
state.eventSink(CallScreenEvent.Hangup)
var callWebView by remember { mutableStateOf<WebView?>(null) }
fun handleBack(fromNative: Boolean = false) {
when (CallScreenBackPressPolicy.resolve(supportPip = pipState.supportPip, hasWebView = callWebView != null, fromNative)) {
CallScreenBackPressAction.EnterPictureInPicture ->
pipState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
CallScreenBackPressAction.DispatchEscapeToWebView ->
callWebView?.dispatchEscKeyEvent()
null -> Timber.d("Back press with unsupported pip is a no-op")
}
}
BackHandler {
handleBack()
handleBack(fromNative = true)
}
if (state.webViewError != null) {
ErrorDialog(
@ -105,6 +109,7 @@ internal fun CallScreenView(
},
onConsoleMessage = onConsoleMessage,
onCreateWebView = { webView ->
callWebView = webView
webView.addBackHandler(onBackPressed = ::handleBack)
val interceptor = WebViewWidgetMessageInterceptor(
webView = webView,
@ -129,6 +134,7 @@ internal fun CallScreenView(
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
},
onDestroyWebView = {
callWebView = null
// Reset audio mode
webViewAudioManager?.onCallStopped()
}
@ -241,15 +247,16 @@ private fun WebView.setup(
private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
addJavascriptInterface(
object {
@Suppress("unused")
@JavascriptInterface
fun onBackPressed() = onBackPressed()
},
JavascriptBackHandlerBridge(callback = onBackPressed),
"backHandler"
)
}
private fun WebView.dispatchEscKeyEvent() {
dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_ESCAPE))
dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_ESCAPE))
}
@PreviewsDayNight
@Composable
internal fun CallScreenViewPreview(
@ -268,3 +275,12 @@ internal fun CallScreenViewPreview(
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
}
internal class JavascriptBackHandlerBridge(
private val callback: () -> Unit,
) {
@JavascriptInterface
fun onBackPressed() {
callback()
}
}

View file

@ -0,0 +1,96 @@
/*
* Copyright (c) 2026 Element Creations 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.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.ui.CallScreenBackPressAction
import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy
import org.junit.Test
class CallScreenBackPressPolicyTest {
@Test
fun `resolve returns dispatch escape when a web view is available and native button is pressed`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = true,
fromNative = true,
)
assertThat(result).isEqualTo(CallScreenBackPressAction.DispatchEscapeToWebView)
}
@Test
fun `resolve dispatch escape when there is a web view and pip is supported on native button press`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = true,
fromNative = true,
)
assertThat(result).isEqualTo(CallScreenBackPressAction.DispatchEscapeToWebView)
}
@Test
fun `resolve returns hangup when there is no web view and pip is not supported from native button`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = false,
fromNative = true,
)
assertThat(result).isNull()
}
@Test
fun `resolve returns hangup when there is no web view even though pip is supported from native button`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = false,
fromNative = true,
)
assertThat(result).isNull()
}
@Test
fun `resolve goes to pip if its not from native but from the webview`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = true,
fromNative = false,
)
assertThat(result).isEqualTo(CallScreenBackPressAction.EnterPictureInPicture)
}
@Test
fun `resolve hangs up if its not from native but from the webview and pip is not supported`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = true,
fromNative = false,
)
assertThat(result).isNull()
}
@Test
fun `invalid cases (event comes from webview but there is now webview) all result in hangup`() {
val withPipSupport = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = false,
fromNative = false,
)
assertThat(withPipSupport).isNull()
val withOutPipSupport = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = false,
fromNative = false,
)
assertThat(withOutPipSupport).isNull()
}
}

View file

@ -0,0 +1,151 @@
/*
* Copyright (c) 2026 Element Creations 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.ui
import android.view.KeyEvent
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenState
import io.element.android.features.call.impl.ui.CallScreenView
import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge
import io.element.android.features.call.impl.ui.aCallScreenState
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.pressBackKey
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import org.robolectric.annotation.Resetter
import org.robolectric.shadows.ShadowWebView
@OptIn(ExperimentalTestApi::class)
@RunWith(AndroidJUnit4::class)
class CallScreenViewTest {
@Test
fun `pressing back key triggers hangup when no web view is available and pip is unsupported`() = runAndroidComposeUiTest {
val callEvents = EventsRecorder<CallScreenEvent>()
setCallScreenView(
state = aCallScreenState(eventSink = callEvents),
useInspectionMode = true,
)
pressBackKey()
callEvents.assertEmpty()
}
@Config(shadows = [RecordingShadowWebView::class])
@Test
fun `pressing back key dispatches escape key events to web view when pip is unsupported`() = runAndroidComposeUiTest {
setCallScreenView(
state = aCallScreenState(),
useInspectionMode = false,
pipState = aPictureInPictureState(supportPip = false),
)
pressBackKey()
val dispatchedEvents = RecordingShadowWebView.dispatchedEvents
assertEquals(2, dispatchedEvents.size)
assertEquals(KeyEvent.ACTION_DOWN, dispatchedEvents[0].action)
assertEquals(KeyEvent.KEYCODE_ESCAPE, dispatchedEvents[0].keyCode)
assertEquals(KeyEvent.ACTION_UP, dispatchedEvents[1].action)
assertEquals(KeyEvent.KEYCODE_ESCAPE, dispatchedEvents[1].keyCode)
}
@Config(shadows = [RecordingShadowWebView::class])
@Test
fun `web view javascript back handler emits pip event when pip is supported`() = runAndroidComposeUiTest {
val pipEvents = EventsRecorder<PictureInPictureEvent>()
setCallScreenView(
state = aCallScreenState(),
useInspectionMode = false,
pipState = aPictureInPictureState(
supportPip = true,
eventSink = pipEvents,
),
)
runOnIdle {
RecordingShadowWebView.invokeJavascriptBackHandler()
}
pipEvents.assertSize(2)
pipEvents.assertTrue(0) { it is PictureInPictureEvent.SetPipController }
pipEvents.assertTrue(1) { it is PictureInPictureEvent.EnterPictureInPicture }
}
}
@OptIn(ExperimentalTestApi::class)
private fun <A : ComponentActivity> AndroidComposeUiTest<A>.setCallScreenView(
state: CallScreenState,
useInspectionMode: Boolean,
pipState: PictureInPictureState = aPictureInPictureState(supportPip = false),
) {
setContent {
// Inspection mode disables AndroidView creation; keep it configurable per test.
CompositionLocalProvider(LocalInspectionMode provides useInspectionMode) {
CallScreenView(
state = state,
pipState = pipState,
onConsoleMessage = {},
requestPermissions = { _, _ -> },
)
}
}
}
@Implements(WebView::class)
internal class RecordingShadowWebView : ShadowWebView() {
companion object {
val dispatchedEvents = mutableListOf<KeyEvent>()
private var backHandlerJavascriptInterface: JavascriptBackHandlerBridge? = null
@Resetter
@JvmStatic
@Suppress("unused")
fun resetRecordedEvents() {
dispatchedEvents.clear()
backHandlerJavascriptInterface = null
}
fun invokeJavascriptBackHandler() {
val backHandler = checkNotNull(backHandlerJavascriptInterface) { "Expected backHandler JavaScript interface to be registered" }
backHandler.onBackPressed()
}
}
@Implementation
protected override fun addJavascriptInterface(`object`: Any, name: String) {
super.addJavascriptInterface(`object`, name)
if (name == "backHandler") {
backHandlerJavascriptInterface = `object` as? JavascriptBackHandlerBridge
}
}
@Implementation
@Suppress("unused")
fun dispatchKeyEvent(event: KeyEvent): Boolean {
dispatchedEvents += KeyEvent(event)
return false
}
}

View file

@ -23,9 +23,11 @@ import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import io.element.android.libraries.ui.strings.CommonStrings
import org.junit.rules.TestRule
val trueMatcher = SemanticsMatcher("true matcher") { true }
@ -48,6 +50,14 @@ fun AndroidComposeUiTest<ComponentActivity>.pressBack() {
onNode(hasContentDescription(text)).performClick()
}
/**
* Press the back button in the app bar.
*/
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBack() {
val text = activity.getString(CommonStrings.action_back)
onNode(hasContentDescription(text)).performClick()
}
/**
* Press the back key.
*/
@ -55,6 +65,13 @@ fun AndroidComposeUiTest<ComponentActivity>.pressBackKey() {
activity!!.onBackPressedDispatcher.onBackPressed()
}
/**
* Press the back key.
*/
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBackKey() {
activity.onBackPressedDispatcher.onBackPressed()
}
fun SemanticsNodeInteractionsProvider.pressTag(tag: String) {
onNode(hasTestTag(tag)).performClick()
}