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:
parent
e010f2772d
commit
c8fa430cb6
5 changed files with 317 additions and 11 deletions
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,16 +64,20 @@ internal fun CallScreenView(
|
||||||
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun handleBack() {
|
var callWebView by remember { mutableStateOf<WebView?>(null) }
|
||||||
if (pipState.supportPip) {
|
|
||||||
pipState.eventSink.invoke(PictureInPictureEvent.EnterPictureInPicture)
|
fun handleBack(fromNative: Boolean = false) {
|
||||||
} else {
|
when (CallScreenBackPressPolicy.resolve(supportPip = pipState.supportPip, hasWebView = callWebView != null, fromNative)) {
|
||||||
state.eventSink(CallScreenEvent.Hangup)
|
CallScreenBackPressAction.EnterPictureInPicture ->
|
||||||
|
pipState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
|
||||||
|
CallScreenBackPressAction.DispatchEscapeToWebView ->
|
||||||
|
callWebView?.dispatchEscKeyEvent()
|
||||||
|
null -> Timber.d("Back press with unsupported pip is a no-op")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler {
|
BackHandler {
|
||||||
handleBack()
|
handleBack(fromNative = true)
|
||||||
}
|
}
|
||||||
if (state.webViewError != null) {
|
if (state.webViewError != null) {
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
|
|
@ -105,6 +109,7 @@ internal fun CallScreenView(
|
||||||
},
|
},
|
||||||
onConsoleMessage = onConsoleMessage,
|
onConsoleMessage = onConsoleMessage,
|
||||||
onCreateWebView = { webView ->
|
onCreateWebView = { webView ->
|
||||||
|
callWebView = webView
|
||||||
webView.addBackHandler(onBackPressed = ::handleBack)
|
webView.addBackHandler(onBackPressed = ::handleBack)
|
||||||
val interceptor = WebViewWidgetMessageInterceptor(
|
val interceptor = WebViewWidgetMessageInterceptor(
|
||||||
webView = webView,
|
webView = webView,
|
||||||
|
|
@ -129,6 +134,7 @@ internal fun CallScreenView(
|
||||||
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
|
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
|
||||||
},
|
},
|
||||||
onDestroyWebView = {
|
onDestroyWebView = {
|
||||||
|
callWebView = null
|
||||||
// Reset audio mode
|
// Reset audio mode
|
||||||
webViewAudioManager?.onCallStopped()
|
webViewAudioManager?.onCallStopped()
|
||||||
}
|
}
|
||||||
|
|
@ -241,15 +247,16 @@ private fun WebView.setup(
|
||||||
|
|
||||||
private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
|
private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
|
||||||
addJavascriptInterface(
|
addJavascriptInterface(
|
||||||
object {
|
JavascriptBackHandlerBridge(callback = onBackPressed),
|
||||||
@Suppress("unused")
|
|
||||||
@JavascriptInterface
|
|
||||||
fun onBackPressed() = onBackPressed()
|
|
||||||
},
|
|
||||||
"backHandler"
|
"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
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CallScreenViewPreview(
|
internal fun CallScreenViewPreview(
|
||||||
|
|
@ -268,3 +275,12 @@ internal fun CallScreenViewPreview(
|
||||||
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
|
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
|
||||||
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
|
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class JavascriptBackHandlerBridge(
|
||||||
|
private val callback: () -> Unit,
|
||||||
|
) {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onBackPressed() {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,9 +23,11 @@ import androidx.compose.ui.test.hasContentDescription
|
||||||
import androidx.compose.ui.test.hasTestTag
|
import androidx.compose.ui.test.hasTestTag
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.isDialog
|
import androidx.compose.ui.test.isDialog
|
||||||
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
|
||||||
val trueMatcher = SemanticsMatcher("true matcher") { true }
|
val trueMatcher = SemanticsMatcher("true matcher") { true }
|
||||||
|
|
||||||
|
|
@ -48,6 +50,14 @@ fun AndroidComposeUiTest<ComponentActivity>.pressBack() {
|
||||||
onNode(hasContentDescription(text)).performClick()
|
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.
|
* Press the back key.
|
||||||
*/
|
*/
|
||||||
|
|
@ -55,6 +65,13 @@ fun AndroidComposeUiTest<ComponentActivity>.pressBackKey() {
|
||||||
activity!!.onBackPressedDispatcher.onBackPressed()
|
activity!!.onBackPressedDispatcher.onBackPressed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Press the back key.
|
||||||
|
*/
|
||||||
|
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.pressBackKey() {
|
||||||
|
activity.onBackPressedDispatcher.onBackPressed()
|
||||||
|
}
|
||||||
|
|
||||||
fun SemanticsNodeInteractionsProvider.pressTag(tag: String) {
|
fun SemanticsNodeInteractionsProvider.pressTag(tag: String) {
|
||||||
onNode(hasTestTag(tag)).performClick()
|
onNode(hasTestTag(tag)).performClick()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue