diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicy.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicy.kt new file mode 100644 index 0000000000..cd47cd8bb1 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicy.kt @@ -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 + } + } +} 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 095d511e0e..2537eb739c 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 @@ -64,16 +64,20 @@ internal fun CallScreenView( requestPermissions: (Array, 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(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() + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt new file mode 100644 index 0000000000..f07f7039d3 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt @@ -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() + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt new file mode 100644 index 0000000000..99aaee6f39 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt @@ -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() + + 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() + + 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 AndroidComposeUiTest.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() + 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 + } +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt index a473e6bd22..232116b385 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt @@ -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.pressBack() { onNode(hasContentDescription(text)).performClick() } +/** + * Press the back button in the app bar. + */ +fun AndroidComposeTestRule.pressBack() { + val text = activity.getString(CommonStrings.action_back) + onNode(hasContentDescription(text)).performClick() +} + /** * Press the back key. */ @@ -55,6 +65,13 @@ fun AndroidComposeUiTest.pressBackKey() { activity!!.onBackPressedDispatcher.onBackPressed() } +/** + * Press the back key. + */ +fun AndroidComposeTestRule.pressBackKey() { + activity.onBackPressedDispatcher.onBackPressed() +} + fun SemanticsNodeInteractionsProvider.pressTag(tag: String) { onNode(hasTestTag(tag)).performClick() }