diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt index 675e1e27e0..4785efe650 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt @@ -11,6 +11,6 @@ import io.element.android.features.call.impl.utils.WidgetMessageInterceptor sealed interface CallScreenEvents { data object Hangup : CallScreenEvents - data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : - CallScreenEvents + data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents + data class OnWebViewError(val description: String?) : CallScreenEvents } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt index 4a6ec60e90..3a4a31f190 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -78,6 +78,8 @@ class CallScreenPresenter @AssistedInject constructor( val callWidgetDriver = remember { mutableStateOf(null) } val messageInterceptor = remember { mutableStateOf(null) } var isJoinedCall by rememberSaveable { mutableStateOf(false) } + var ignoreWebViewError by rememberSaveable { mutableStateOf(false) } + var webViewError by remember { mutableStateOf(null) } val languageTag = languageTagProvider.provideLanguageTag() val theme = if (ElementTheme.isLightTheme) "light" else "dark" DisposableEffect(Unit) { @@ -125,6 +127,8 @@ class CallScreenPresenter @AssistedInject constructor( LaunchedEffect(Unit) { interceptor.interceptedMessages .onEach { + // We are receiving messages from the WebView, consider that the application is loaded + ignoreWebViewError = true // Relay message to Widget Driver callWidgetDriver.value?.send(it) @@ -163,11 +167,18 @@ class CallScreenPresenter @AssistedInject constructor( is CallScreenEvents.SetupMessageChannels -> { messageInterceptor.value = event.widgetMessageInterceptor } + is CallScreenEvents.OnWebViewError -> { + if (!ignoreWebViewError) { + webViewError = event.description.orEmpty() + } + // Else ignore the error, give a chance the Element Call to recover by itself. + } } } return CallScreenState( urlState = urlState.value, + webViewError = webViewError, userAgent = userAgent, isInWidgetMode = isInWidgetMode, eventSink = { handleEvents(it) }, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt index 09fabfd0b0..48a4672e1a 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt @@ -11,6 +11,7 @@ import io.element.android.libraries.architecture.AsyncData data class CallScreenState( val urlState: AsyncData, + val webViewError: String?, val userAgent: String, val isInWidgetMode: Boolean, val eventSink: (CallScreenEvents) -> Unit, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt index fc39c71c23..bb4794749d 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt @@ -16,17 +16,20 @@ open class CallScreenStateProvider : PreviewParameterProvider { aCallScreenState(), aCallScreenState(urlState = AsyncData.Loading()), aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))), + aCallScreenState(webViewError = "Error details from WebView"), ) } internal fun aCallScreenState( urlState: AsyncData = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"), + webViewError: String? = null, userAgent: String = "", isInWidgetMode: Boolean = false, eventSink: (CallScreenEvents) -> Unit = {}, ): CallScreenState { return CallScreenState( urlState = urlState, + webViewError = webViewError, userAgent = userAgent, isInWidgetMode = isInWidgetMode, eventSink = eventSink, 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 4641e4e7bc..5fa4856476 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 @@ -85,35 +85,48 @@ internal fun CallScreenView( BackHandler { handleBack() } - CallWebView( - modifier = Modifier + 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(CallScreenEvents.Hangup) }, + ) + } else { + CallWebView( + modifier = Modifier .padding(padding) .consumeWindowInsets(padding) .fillMaxSize(), - url = state.urlState, - userAgent = state.userAgent, - onPermissionsRequest = { request -> - val androidPermissions = mapWebkitPermissions(request.resources) - val callback: RequestPermissionCallback = { request.grant(it) } - requestPermissions(androidPermissions.toTypedArray(), callback) - }, - onWebViewCreate = { webView -> - val interceptor = WebViewWidgetMessageInterceptor(webView) - state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) - val pipController = WebViewPipController(webView) - pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) + url = state.urlState, + userAgent = state.userAgent, + onPermissionsRequest = { request -> + val androidPermissions = mapWebkitPermissions(request.resources) + val callback: RequestPermissionCallback = { request.grant(it) } + requestPermissions(androidPermissions.toTypedArray(), callback) + }, + onWebViewCreate = { webView -> + val interceptor = WebViewWidgetMessageInterceptor( + webView = webView, + onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) }, + ) + state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) + val pipController = WebViewPipController(webView) + pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) + } + ) + when (state.urlState) { + AsyncData.Uninitialized, + is AsyncData.Loading -> + ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait)) + is AsyncData.Failure -> + ErrorDialog( + content = state.urlState.error.message.orEmpty(), + onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, + ) + is AsyncData.Success -> Unit } - ) - when (state.urlState) { - AsyncData.Uninitialized, - is AsyncData.Loading -> - ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait)) - is AsyncData.Failure -> - ErrorDialog( - content = state.urlState.error.message.orEmpty(), - onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, - ) - is AsyncData.Success -> Unit } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt index 6f200e550a..609c04a0e6 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt @@ -8,16 +8,23 @@ package io.element.android.features.call.impl.utils import android.graphics.Bitmap +import android.net.http.SslError import android.webkit.JavascriptInterface +import android.webkit.SslErrorHandler +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import io.element.android.features.call.impl.BuildConfig import kotlinx.coroutines.flow.MutableSharedFlow +import timber.log.Timber class WebViewWidgetMessageInterceptor( private val webView: WebView, + private val onError: (String?) -> Unit, ) : WidgetMessageInterceptor { companion object { // We call both the WebMessageListener and the JavascriptInterface objects in JS with this @@ -45,16 +52,35 @@ class WebViewWidgetMessageInterceptor( if (message.data.response && message.data.api == "toWidget" || !message.data.response && message.data.api == "fromWidget") { let json = JSON.stringify(event.data) - ${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG } } + ${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG }} $LISTENER_NAME.postMessage(json); } else { - ${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG } } + ${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG }} } }); """.trimIndent(), null ) } + + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + // No network for instance, transmit the error + Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}") + onError(error?.description?.toString()) + super.onReceivedError(view, request, error) + } + + override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) { + Timber.e("onReceivedHttpError error: ${errorResponse?.statusCode} ${errorResponse?.reasonPhrase}") + onError(errorResponse?.statusCode.toString()) + super.onReceivedHttpError(view, request, errorResponse) + } + + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { + Timber.e("onReceivedSslError error: ${error?.primaryError}") + onError(error?.primaryError?.toString()) + super.onReceivedSslError(view, handler, error) + } } // Create a WebMessageListener, which will receive messages from the WebView and reply to them diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index 77326b0d7d..7390b5fde9 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -71,6 +71,7 @@ class CallScreenPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io")) + assertThat(initialState.webViewError).isNull() assertThat(initialState.isInWidgetMode).isFalse() analyticsLambda.assertions().isNeverCalled() joinedCallLambda.assertions().isCalledOnce() @@ -270,6 +271,48 @@ class CallScreenPresenterTest { assert(stopSyncLambda).isCalledOnce() } + @Test + fun `present - error from WebView are updating the state`() = runTest { + val presenter = createCallScreenPresenter( + callType = CallType.ExternalUrl("https://call.element.io"), + activeCallManager = FakeActiveCallManager(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Wait until the URL is loaded + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) + val finalState = awaitItem() + assertThat(finalState.webViewError).isEqualTo("A Webview error") + } + } + + @Test + fun `present - error from WebView are ignored if Element Call is loaded`() = runTest { + val presenter = createCallScreenPresenter( + callType = CallType.ExternalUrl("https://call.element.io"), + activeCallManager = FakeActiveCallManager(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Wait until the URL is loaded + skipItems(1) + val initialState = awaitItem() + + val messageInterceptor = FakeWidgetMessageInterceptor() + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) + // Emit a message + messageInterceptor.givenInterceptedMessage("A message") + // WebView emits an error, but it will be ignored + initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) + val finalState = awaitItem() + assertThat(finalState.webViewError).isNull() + } + } + private fun TestScope.createCallScreenPresenter( callType: CallType, navigator: CallScreenNavigator = FakeCallScreenNavigator(), diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png new file mode 100644 index 0000000000..6ed4f78a66 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1067d901572528e98cb735ffd5a4c8d340ab62718fb41b1c14b9d8c8019d34c4 +size 19192 diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png new file mode 100644 index 0000000000..d8ca8ea365 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4e900d1e69e56e6b0d1e2a18e23a087051b3452a173678494217326399e0948 +size 17340