diff --git a/README.md b/README.md index 576bc218..26b5bf66 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,36 @@ val navigator = ) ``` +### Intercepting Error Responses + +You can intercept failed navigations and decide whether to suppress the default platform error UI or continue with normal handling. This is useful for showing a custom error screen, logging, or redirecting. + +![ErrorResponse Interceptor Demo](media/error-response-interceptor.gif) + +#### API +- `ErrorResponse`: encapsulates platform-agnostic error details (`errorCode`, `description`, and the failing `url` when available). +- `ErrorResponseInterceptor`: callback invoked when a page/resource load fails. +- `ShouldStopLoading`: return `true` to stop default handling; return `false` to allow it. + +#### Quick start +```kotlin +val webViewNavigator = rememberWebViewNavigator( + errorResponseInterceptor = object : ErrorResponseInterceptor { + override fun onInterceptErrorResponse( + response: ErrorResponse, + navigator: WebViewNavigator + ): ShouldStopLoading { + // Show a custom UI or navigate elsewhere + return true // suppress default error handling + } + } +) + +WebView( + state = webViewState, + navigator = webViewNavigator, +) +``` ## WebSettings Starting from version 1.3.0, this library allows users to customize web settings. diff --git a/sample/iosApp/iosApp.xcodeproj/project.pbxproj b/sample/iosApp/iosApp.xcodeproj/project.pbxproj index 6016cf1e..ddcb0db1 100644 --- a/sample/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/sample/iosApp/iosApp.xcodeproj/project.pbxproj @@ -320,7 +320,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = 9MQUMBML8C; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -352,7 +352,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = 9MQUMBML8C; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/sample/shared/src/commonMain/composeResources/files/samples/errorResponse.html b/sample/shared/src/commonMain/composeResources/files/samples/errorResponse.html new file mode 100644 index 00000000..8d2ece81 --- /dev/null +++ b/sample/shared/src/commonMain/composeResources/files/samples/errorResponse.html @@ -0,0 +1,12 @@ + + +
+ + + Compose WebView Multiplatform + + +

Compose WebView Multiplatform

+

Error Response

+ + diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/ErrorResponseSample.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/ErrorResponseSample.kt new file mode 100644 index 00000000..7e86cf5c --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/ErrorResponseSample.kt @@ -0,0 +1,178 @@ +package com.kevinnzou.sample + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import co.touchlab.kermit.Logger +import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge +import com.multiplatform.webview.response.ErrorResponse +import com.multiplatform.webview.response.ErrorResponseInterceptor +import com.multiplatform.webview.response.ShouldStopLoading +import com.multiplatform.webview.util.KLogSeverity +import com.multiplatform.webview.web.WebView +import com.multiplatform.webview.web.WebViewFileReadType +import com.multiplatform.webview.web.WebViewNavigator +import com.multiplatform.webview.web.WebViewState +import com.multiplatform.webview.web.rememberWebViewNavigator +import com.multiplatform.webview.web.rememberWebViewStateWithHTMLFile +import compose_webview_multiplatform.sample.shared.generated.resources.Res + +/** + * Created By Matthias Hennemeyer On 2025/8/8 + * + * Sample intercepting an error response + * + * Note: Developers targeting the Desktop platform should refer to + * [README.desktop.md](https://github.com/KevinnZou/compose-webview-multiplatform/blob/main/README.desktop.md) + * for setup instructions first. + */ +@Composable +internal fun ErrorResponseSample(navHostController: NavHostController? = null) { + val webViewState = + rememberWebViewStateWithHTMLFile( + fileName = Res.getUri("files/samples/errorResponse.html"), + readType = WebViewFileReadType.COMPOSE_RESOURCE_FILES, + ) + val errorUrl = "http://matthiashennemeyer.com/404-on-android-and-tls-error-on-ios" + var errorResponse by remember { mutableStateOf(null) } + val webViewNavigator = rememberWebViewNavigator( + errorResponseInterceptor = object : ErrorResponseInterceptor { + override fun onInterceptErrorResponse( + response: ErrorResponse, + navigator: WebViewNavigator + ): ShouldStopLoading { + Logger.e("errorResponseInterceptor: code: ${response.errorCode} description: ${response.description}") + errorResponse = response + return true + } + } + ) + val jsBridge = rememberWebViewJsBridge(webViewNavigator) + LaunchedEffect(Unit) { + init(webViewState) + } + MaterialTheme { + Scaffold { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + Column { + TopAppBar( + modifier = + Modifier + .background( + color = MaterialTheme.colors.primary, + ).padding( + top = + WindowInsets.statusBars + .asPaddingValues() + .calculateTopPadding(), + ), + title = { Text(text = "Error Response Sample") }, + navigationIcon = { + IconButton(onClick = { + navHostController?.popBackStack() + }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + Button( + onClick = { + webViewNavigator.loadUrl(errorUrl) + }, + modifier = Modifier.padding(horizontal = 4.dp), + colors = + ButtonDefaults.buttonColors( + backgroundColor = Color.White, + contentColor = MaterialTheme.colors.primary, + ), + ) { + Text( + "Trigger Error", + style = MaterialTheme.typography.caption, + ) + } + }, + + ) + + AnimatedVisibility(visible = (errorResponse != null)) { + Box(modifier = Modifier.fillMaxWidth()) { + Button( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(horizontal = 4.dp) + , + onClick = { errorResponse = null } + ) { Text(text = "X") } + } + + + if (errorResponse != null) { + Text(text = "Error: ${errorResponse!!.description}\nCode: ${errorResponse!!.errorCode}") + } + } + + // WebView without overlay buttons + WebView( + state = webViewState, + modifier = Modifier.fillMaxSize(), + captureBackPresses = false, + navigator = webViewNavigator, + webViewJsBridge = jsBridge, + ) + } + } + } + } +} + +fun init(webViewState: WebViewState) { + webViewState.webSettings.apply { + zoomLevel = 1.0 + isJavaScriptEnabled = true + logSeverity = KLogSeverity.Debug + allowFileAccessFromFileURLs = true + allowUniversalAccessFromFileURLs = true + androidWebSettings.apply { + isAlgorithmicDarkeningAllowed = true + safeBrowsingEnabled = true + allowFileAccess = true + } + } +} diff --git a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/WebViewApp.kt b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/WebViewApp.kt index 60550c1f..de344824 100644 --- a/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/WebViewApp.kt +++ b/sample/shared/src/commonMain/kotlin/com/kevinnzou/sample/WebViewApp.kt @@ -60,6 +60,9 @@ internal fun WebViewApp() { composable("file") { FileChooseWebViewSample(controller) } + composable("error") { + ErrorResponseSample(controller) + } } } @@ -116,6 +119,12 @@ fun MainScreen(controller: NavController) { }) { Text("File Choose Sample", fontSize = 18.sp) } + Spacer(modifier = Modifier.height(20.dp)) + Button(onClick = { + controller.navigate("error") + }) { + Text("Error Response Sample", fontSize = 18.sp) + } } } } diff --git a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt index 2ac8cc58..82b994b2 100644 --- a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt +++ b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt @@ -33,6 +33,7 @@ import com.multiplatform.webview.jsbridge.ConsoleBridge import com.multiplatform.webview.jsbridge.WebViewJsBridge import com.multiplatform.webview.request.WebRequest import com.multiplatform.webview.request.WebRequestInterceptResult +import com.multiplatform.webview.response.ErrorResponse import com.multiplatform.webview.setting.PlatformWebSettings import com.multiplatform.webview.util.InternalStoragePathHandler import com.multiplatform.webview.util.KLogger @@ -357,6 +358,31 @@ open class AccompanistWebViewClient : WebViewClient() { navigator.canGoForward = view.canGoForward() } + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + super.onReceivedHttpError(view, request, errorResponse) + KLogger.e { + "onReceivedHttpError: $errorResponse" + } + + + if (navigator.errorResponseInterceptor?.onInterceptErrorResponse( + ErrorResponse( + url = request?.url.toString(), + errorCode = errorResponse?.statusCode?.toLong(), + description = errorResponse?.reasonPhrase + ), + navigator + ) ?: false + ) { + view?.stopLoading() + navigator.stopLoading() + } + } + override fun onReceivedError( view: WebView, request: WebResourceRequest?, @@ -381,6 +407,17 @@ open class AccompanistWebViewClient : WebViewClient() { ), ) } + if (navigator.errorResponseInterceptor?.onInterceptErrorResponse( + ErrorResponse( + url = request?.url.toString(), + errorCode = error?.errorCode?.toLong(), + description = error?.description.toString()), + navigator + ) ?: false + ) { + view.stopLoading() + navigator.stopLoading() + } } override fun shouldOverrideUrlLoading( diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/response/ErrorResponse.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/response/ErrorResponse.kt new file mode 100644 index 00000000..35c51e6d --- /dev/null +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/response/ErrorResponse.kt @@ -0,0 +1,9 @@ +package com.multiplatform.webview.response + +import com.multiplatform.webview.web.WebContent + +data class ErrorResponse( + val description: String? = null, + val errorCode: Long? = null, + val url: String? = null +) \ No newline at end of file diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/response/ErrorResponseInterceptor.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/response/ErrorResponseInterceptor.kt new file mode 100644 index 00000000..bd8d1262 --- /dev/null +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/response/ErrorResponseInterceptor.kt @@ -0,0 +1,11 @@ +package com.multiplatform.webview.response + +import com.multiplatform.webview.web.WebViewNavigator + +typealias ShouldStopLoading = Boolean +interface ErrorResponseInterceptor { + fun onInterceptErrorResponse( + response: ErrorResponse, + navigator: WebViewNavigator, + ): ShouldStopLoading +} \ No newline at end of file diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt index ddf0b405..785e1619 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.multiplatform.webview.request.RequestInterceptor +import com.multiplatform.webview.response.ErrorResponseInterceptor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -28,6 +29,7 @@ import kotlinx.coroutines.withContext class WebViewNavigator( val coroutineScope: CoroutineScope, val requestInterceptor: RequestInterceptor? = null, + val errorResponseInterceptor: ErrorResponseInterceptor? = null, ) { /** * Sealed class for constraining possible navigation events. @@ -310,4 +312,5 @@ class WebViewNavigator( fun rememberWebViewNavigator( coroutineScope: CoroutineScope = rememberCoroutineScope(), requestInterceptor: RequestInterceptor? = null, -): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope, requestInterceptor) } + errorResponseInterceptor: ErrorResponseInterceptor? = null, +): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope, requestInterceptor, errorResponseInterceptor) } diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt index 78b2e6bb..3b3307ec 100644 --- a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt @@ -2,6 +2,7 @@ package com.multiplatform.webview.web import com.multiplatform.webview.request.WebRequest import com.multiplatform.webview.request.WebRequestInterceptResult +import com.multiplatform.webview.response.ErrorResponse import com.multiplatform.webview.util.KLogger import com.multiplatform.webview.util.getPlatformVersionDouble import com.multiplatform.webview.util.notZero @@ -117,6 +118,15 @@ class WKNavigationDelegate( KLogger.e { "didFailNavigation" } + if (navigator.errorResponseInterceptor?.let { errorResponseInterceptor -> + errorResponseInterceptor.onInterceptErrorResponse( + ErrorResponse(withError.description, withError.code, webView.URL.toString()), + navigator + ) + } ?: false) { + webView.stopLoading() + navigator.stopLoading() + } } override fun webView(