Merge pull request #3527 from element-hq/feature/bma/elementCallNoNetwork
Handle no network error when starting Element Call.
This commit is contained in:
commit
87f642918e
9 changed files with 132 additions and 29 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1067d901572528e98cb735ffd5a4c8d340ab62718fb41b1c14b9d8c8019d34c4
|
||||||
|
size 19192
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b4e900d1e69e56e6b0d1e2a18e23a087051b3452a173678494217326399e0948
|
||||||
|
size 17340
|
||||||
Loading…
Add table
Add a link
Reference in a new issue