Merge pull request #3527 from element-hq/feature/bma/elementCallNoNetwork

Handle no network error when starting Element Call.
This commit is contained in:
Benoit Marty 2024-09-26 14:43:50 +02:00 committed by GitHub
commit 87f642918e
9 changed files with 132 additions and 29 deletions

View file

@ -11,6 +11,6 @@ import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
sealed interface CallScreenEvents { sealed interface CallScreenEvents {
data object Hangup : CallScreenEvents data object Hangup : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
CallScreenEvents data class OnWebViewError(val description: String?) : CallScreenEvents
} }

View file

@ -78,6 +78,8 @@ class CallScreenPresenter @AssistedInject constructor(
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) } val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) }
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) } val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
var isJoinedCall by rememberSaveable { mutableStateOf(false) } var isJoinedCall by rememberSaveable { mutableStateOf(false) }
var ignoreWebViewError by rememberSaveable { mutableStateOf(false) }
var webViewError by remember { mutableStateOf<String?>(null) }
val languageTag = languageTagProvider.provideLanguageTag() val languageTag = languageTagProvider.provideLanguageTag()
val theme = if (ElementTheme.isLightTheme) "light" else "dark" val theme = if (ElementTheme.isLightTheme) "light" else "dark"
DisposableEffect(Unit) { DisposableEffect(Unit) {
@ -125,6 +127,8 @@ class CallScreenPresenter @AssistedInject constructor(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
interceptor.interceptedMessages interceptor.interceptedMessages
.onEach { .onEach {
// We are receiving messages from the WebView, consider that the application is loaded
ignoreWebViewError = true
// Relay message to Widget Driver // Relay message to Widget Driver
callWidgetDriver.value?.send(it) callWidgetDriver.value?.send(it)
@ -163,11 +167,18 @@ class CallScreenPresenter @AssistedInject constructor(
is CallScreenEvents.SetupMessageChannels -> { is CallScreenEvents.SetupMessageChannels -> {
messageInterceptor.value = event.widgetMessageInterceptor 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( return CallScreenState(
urlState = urlState.value, urlState = urlState.value,
webViewError = webViewError,
userAgent = userAgent, userAgent = userAgent,
isInWidgetMode = isInWidgetMode, isInWidgetMode = isInWidgetMode,
eventSink = { handleEvents(it) }, eventSink = { handleEvents(it) },

View file

@ -11,6 +11,7 @@ import io.element.android.libraries.architecture.AsyncData
data class CallScreenState( data class CallScreenState(
val urlState: AsyncData<String>, val urlState: AsyncData<String>,
val webViewError: String?,
val userAgent: String, val userAgent: String,
val isInWidgetMode: Boolean, val isInWidgetMode: Boolean,
val eventSink: (CallScreenEvents) -> Unit, val eventSink: (CallScreenEvents) -> Unit,

View file

@ -16,17 +16,20 @@ open class CallScreenStateProvider : PreviewParameterProvider<CallScreenState> {
aCallScreenState(), aCallScreenState(),
aCallScreenState(urlState = AsyncData.Loading()), aCallScreenState(urlState = AsyncData.Loading()),
aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))), aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))),
aCallScreenState(webViewError = "Error details from WebView"),
) )
} }
internal fun aCallScreenState( internal fun aCallScreenState(
urlState: AsyncData<String> = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"), urlState: AsyncData<String> = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"),
webViewError: String? = null,
userAgent: String = "", userAgent: String = "",
isInWidgetMode: Boolean = false, isInWidgetMode: Boolean = false,
eventSink: (CallScreenEvents) -> Unit = {}, eventSink: (CallScreenEvents) -> Unit = {},
): CallScreenState { ): CallScreenState {
return CallScreenState( return CallScreenState(
urlState = urlState, urlState = urlState,
webViewError = webViewError,
userAgent = userAgent, userAgent = userAgent,
isInWidgetMode = isInWidgetMode, isInWidgetMode = isInWidgetMode,
eventSink = eventSink, eventSink = eventSink,

View file

@ -85,35 +85,48 @@ internal fun CallScreenView(
BackHandler { BackHandler {
handleBack() handleBack()
} }
CallWebView( if (state.webViewError != null) {
modifier = Modifier 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) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
.fillMaxSize(), .fillMaxSize(),
url = state.urlState, url = state.urlState,
userAgent = state.userAgent, userAgent = state.userAgent,
onPermissionsRequest = { request -> onPermissionsRequest = { request ->
val androidPermissions = mapWebkitPermissions(request.resources) val androidPermissions = mapWebkitPermissions(request.resources)
val callback: RequestPermissionCallback = { request.grant(it) } val callback: RequestPermissionCallback = { request.grant(it) }
requestPermissions(androidPermissions.toTypedArray(), callback) requestPermissions(androidPermissions.toTypedArray(), callback)
}, },
onWebViewCreate = { webView -> onWebViewCreate = { webView ->
val interceptor = WebViewWidgetMessageInterceptor(webView) val interceptor = WebViewWidgetMessageInterceptor(
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) webView = webView,
val pipController = WebViewPipController(webView) onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) )
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
} }
} }
} }

View file

@ -8,16 +8,23 @@
package io.element.android.features.call.impl.utils package io.element.android.features.call.impl.utils
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.http.SslError
import android.webkit.JavascriptInterface 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.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.webkit.WebViewCompat import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature import androidx.webkit.WebViewFeature
import io.element.android.features.call.impl.BuildConfig import io.element.android.features.call.impl.BuildConfig
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import timber.log.Timber
class WebViewWidgetMessageInterceptor( class WebViewWidgetMessageInterceptor(
private val webView: WebView, private val webView: WebView,
private val onError: (String?) -> Unit,
) : WidgetMessageInterceptor { ) : WidgetMessageInterceptor {
companion object { companion object {
// We call both the WebMessageListener and the JavascriptInterface objects in JS with this // 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" if (message.data.response && message.data.api == "toWidget"
|| !message.data.response && message.data.api == "fromWidget") { || !message.data.response && message.data.api == "fromWidget") {
let json = JSON.stringify(event.data) 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); $LISTENER_NAME.postMessage(json);
} else { } 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(), """.trimIndent(),
null 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 // Create a WebMessageListener, which will receive messages from the WebView and reply to them

View file

@ -71,6 +71,7 @@ class CallScreenPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io")) assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.webViewError).isNull()
assertThat(initialState.isInWidgetMode).isFalse() assertThat(initialState.isInWidgetMode).isFalse()
analyticsLambda.assertions().isNeverCalled() analyticsLambda.assertions().isNeverCalled()
joinedCallLambda.assertions().isCalledOnce() joinedCallLambda.assertions().isCalledOnce()
@ -270,6 +271,48 @@ class CallScreenPresenterTest {
assert(stopSyncLambda).isCalledOnce() 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( private fun TestScope.createCallScreenPresenter(
callType: CallType, callType: CallType,
navigator: CallScreenNavigator = FakeCallScreenNavigator(), navigator: CallScreenNavigator = FakeCallScreenNavigator(),

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1067d901572528e98cb735ffd5a4c8d340ab62718fb41b1c14b9d8c8019d34c4
size 19192

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b4e900d1e69e56e6b0d1e2a18e23a087051b3452a173678494217326399e0948
size 17340