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 deleted file mode 100644 index cd47cd8bb1..0000000000 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicy.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 ea3668316b..1c68a62f55 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,15 +64,11 @@ internal fun CallScreenView( requestPermissions: (Array, RequestPermissionCallback) -> Unit, modifier: Modifier = Modifier, ) { - 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") + fun handleBack() { + if (pipState.supportPip) { + pipState.eventSink.invoke(PictureInPictureEvent.EnterPictureInPicture) + } else { + state.eventSink(CallScreenEvent.Hangup) } } @@ -80,7 +76,7 @@ internal fun CallScreenView( modifier = modifier, ) { padding -> BackHandler { - handleBack(fromNative = true) + handleBack() } if (state.webViewError != null) { ErrorDialog( @@ -115,7 +111,6 @@ internal fun CallScreenView( }, onConsoleMessage = onConsoleMessage, onCreateWebView = { webView -> - callWebView = webView webView.addBackHandler(onBackPressed = ::handleBack) val interceptor = WebViewWidgetMessageInterceptor( webView = webView, @@ -140,7 +135,6 @@ internal fun CallScreenView( pipState.eventSink(PictureInPictureEvent.SetPipController(pipController)) }, onDestroyWebView = { - callWebView = null // Reset audio mode webViewAudioManager?.onCallStopped() } @@ -149,7 +143,6 @@ internal fun CallScreenView( 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( @@ -157,7 +150,6 @@ internal fun CallScreenView( onSubmit = { state.eventSink(CallScreenEvent.Hangup) }, ) } - is AsyncData.Success -> Unit } } @@ -256,18 +248,15 @@ private fun WebView.setup( private fun WebView.addBackHandler(onBackPressed: () -> Unit) { addJavascriptInterface( - JavascriptBackHandler { - onBackPressed() + object { + @Suppress("unused") + @JavascriptInterface + fun onBackPressed() = 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( @@ -286,8 +275,3 @@ internal fun CallScreenViewPreview( internal fun InvalidAudioDeviceDialogPreview() = ElementPreview { InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {} } - -internal fun interface JavascriptBackHandler { - @JavascriptInterface - fun onBackPressed() -} 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 deleted file mode 100644 index f07f7039d3..0000000000 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * 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 deleted file mode 100644 index e4f9c10a3c..0000000000 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * 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. - */ - -@file:OptIn(ExperimentalTestApi::class) - -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.aPictureInPictureState -import io.element.android.features.call.impl.ui.CallScreenEvent -import io.element.android.features.call.impl.ui.CallScreenView -import io.element.android.features.call.impl.ui.JavascriptBackHandler -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 - -@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, - ) - - 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 } - } -} - -private fun AndroidComposeUiTest.setCallScreenView( - state: io.element.android.features.call.impl.ui.CallScreenState, - useInspectionMode: Boolean, - pipState: io.element.android.features.call.impl.pip.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: JavascriptBackHandler? = 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? JavascriptBackHandler - } - } - - @Implementation - @Suppress("unused") - fun dispatchKeyEvent(event: KeyEvent): Boolean { - dispatchedEvents += KeyEvent(event) - return false - } -}