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
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 {
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
}

View File

@@ -78,6 +78,8 @@ class CallScreenPresenter @AssistedInject constructor(
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) }
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
var isJoinedCall by rememberSaveable { mutableStateOf(false) }
var ignoreWebViewError by rememberSaveable { mutableStateOf(false) }
var webViewError by remember { mutableStateOf<String?>(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) },

View File

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

View File

@@ -16,17 +16,20 @@ open class CallScreenStateProvider : PreviewParameterProvider<CallScreenState> {
aCallScreenState(),
aCallScreenState(urlState = AsyncData.Loading()),
aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))),
aCallScreenState(webViewError = "Error details from WebView"),
)
}
internal fun aCallScreenState(
urlState: AsyncData<String> = 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,

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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(),