diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebFragment.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebFragment.kt index a3aff4e7..2b42d07e 100644 --- a/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebFragment.kt +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebFragment.kt @@ -10,7 +10,9 @@ import dev.hotwire.turbo.demo.util.SIGN_IN_URL import dev.hotwire.turbo.fragments.TurboWebFragment import dev.hotwire.turbo.nav.TurboNavGraphDestination import dev.hotwire.turbo.views.TurboWebView +import dev.hotwire.turbo.errors.HttpError import dev.hotwire.turbo.visit.TurboVisitAction.REPLACE +import dev.hotwire.turbo.errors.TurboVisitError import dev.hotwire.turbo.visit.TurboVisitOptions @TurboNavGraphDestination(uri = "turbo://fragment/web") @@ -58,10 +60,11 @@ open class WebFragment : TurboWebFragment(), NavDestination { menuProgress?.isVisible = false } - override fun onVisitErrorReceived(location: String, errorCode: Int) { - when (errorCode) { - 401 -> navigate(SIGN_IN_URL, TurboVisitOptions(action = REPLACE)) - else -> super.onVisitErrorReceived(location, errorCode) + override fun onVisitErrorReceived(location: String, error: TurboVisitError) { + if (error is HttpError.ClientError.Unauthorized) { + navigate(SIGN_IN_URL, TurboVisitOptions(action = REPLACE)) + } else { + super.onVisitErrorReceived(location, error) } } diff --git a/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebHomeFragment.kt b/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebHomeFragment.kt index 9f6a069c..bee4a2db 100644 --- a/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebHomeFragment.kt +++ b/demo/src/main/kotlin/dev/hotwire/turbo/demo/features/web/WebHomeFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import dev.hotwire.turbo.demo.R import dev.hotwire.turbo.nav.TurboNavGraphDestination +import dev.hotwire.turbo.errors.TurboVisitError @TurboNavGraphDestination(uri = "turbo://fragment/web/home") class WebHomeFragment : WebFragment() { @@ -15,7 +16,7 @@ class WebHomeFragment : WebFragment() { } @SuppressLint("InflateParams") - override fun createErrorView(statusCode: Int): View { + override fun createErrorView(error: TurboVisitError): View { return layoutInflater.inflate(R.layout.error_web_home, null) } diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt index 40279e8e..f7e4e3bb 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt @@ -21,6 +21,7 @@ import dev.hotwire.turbo.views.TurboView import dev.hotwire.turbo.views.TurboWebView import dev.hotwire.turbo.visit.TurboVisit import dev.hotwire.turbo.visit.TurboVisitAction +import dev.hotwire.turbo.errors.TurboVisitError import dev.hotwire.turbo.visit.TurboVisitOptions import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -158,8 +159,8 @@ internal class TurboWebFragmentDelegate( /** * Displays the error view that's implemented via [TurboWebFragmentCallback.createErrorView]. */ - fun showErrorView(code: Int) { - turboView?.addErrorView(callback.createErrorView(code)) + fun showErrorView(error: TurboVisitError) { + turboView?.addErrorView(callback.createErrorView(error)) } // ----------------------------------------------------------------------- @@ -205,19 +206,19 @@ internal class TurboWebFragmentDelegate( navDestination.fragmentViewModel.setTitle(title()) } - override fun onReceivedError(errorCode: Int) { - callback.onVisitErrorReceived(location, errorCode) + override fun onReceivedError(error: TurboVisitError) { + callback.onVisitErrorReceived(location, error) } override fun onRenderProcessGone() { navigator.navigate(location, TurboVisitOptions(action = TurboVisitAction.REPLACE)) } - override fun requestFailedWithStatusCode(visitHasCachedSnapshot: Boolean, statusCode: Int) { + override fun requestFailedWithError(visitHasCachedSnapshot: Boolean, error: TurboVisitError) { if (visitHasCachedSnapshot) { - callback.onVisitErrorReceivedWithCachedSnapshotAvailable(location, statusCode) + callback.onVisitErrorReceivedWithCachedSnapshotAvailable(location, error) } else { - callback.onVisitErrorReceived(location, statusCode) + callback.onVisitErrorReceived(location, error) } } diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt new file mode 100644 index 00000000..1d56d669 --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt @@ -0,0 +1,159 @@ +package dev.hotwire.turbo.errors + +import android.webkit.WebResourceResponse + +/** + * Errors representing HTTP status codes received from the server. + */ +sealed interface HttpError : TurboVisitError { + val statusCode: Int + val reasonPhrase: String? + + /** + * Errors representing HTTP client errors in the 400..499 range. + */ + sealed interface ClientError : HttpError { + data object BadRequest : ClientError { + override val statusCode = 400 + override val reasonPhrase = "Bad Request" + } + + data object Unauthorized : ClientError { + override val statusCode = 401 + override val reasonPhrase = "Unauthorized" + } + + data object Forbidden : ClientError { + override val statusCode = 403 + override val reasonPhrase = "Forbidden" + } + + data object NotFound : ClientError { + override val statusCode = 404 + override val reasonPhrase = "Not Found" + } + + data object MethodNotAllowed : ClientError { + override val statusCode = 405 + override val reasonPhrase = "Method Not Allowed" + } + + data object NotAccessible : ClientError { + override val statusCode = 406 + override val reasonPhrase = "Not Accessible" + } + + data object ProxyAuthenticationRequired : ClientError { + override val statusCode = 407 + override val reasonPhrase = "Proxy Authentication Required" + } + + data object RequestTimeout : ClientError { + override val statusCode = 408 + override val reasonPhrase = "Request Timeout" + } + + data object Conflict : ClientError { + override val statusCode = 409 + override val reasonPhrase = "Conflict" + } + + data object MisdirectedRequest : ClientError { + override val statusCode = 421 + override val reasonPhrase = "Misdirected Request" + } + + data object UnprocessableEntity : ClientError { + override val statusCode = 422 + override val reasonPhrase = "Unprocessable Entity" + } + + data object PreconditionRequired : ClientError { + override val statusCode = 428 + override val reasonPhrase = "Precondition Required" + } + + data object TooManyRequests : ClientError { + override val statusCode = 429 + override val reasonPhrase = "Too Many Requests" + } + + data class Other( + override val statusCode: Int, + override val reasonPhrase: String? + ) : ClientError + } + + /** + * Errors representing HTTP server errors in the 500..599 range. + */ + sealed interface ServerError : HttpError { + data object InternalServerError : ServerError { + override val statusCode = 500 + override val reasonPhrase = "Internal Server Error" + } + + data object NotImplemented : ServerError { + override val statusCode = 501 + override val reasonPhrase = "Not Implemented" + } + + data object BadGateway : ServerError { + override val statusCode = 502 + override val reasonPhrase = "Bad Gateway" + } + + data object ServiceUnavailable : ServerError { + override val statusCode = 503 + override val reasonPhrase = "Service Unavailable" + } + + data object GatewayTimeout : ServerError { + override val statusCode = 504 + override val reasonPhrase = "Gateway Timeout" + } + + data object HttpVersionNotSupported : ServerError { + override val statusCode = 505 + override val reasonPhrase = "Http Version Not Supported" + } + + data class Other( + override val statusCode: Int, + override val reasonPhrase: String? + ) : ServerError + } + + data class UnknownError( + override val statusCode: Int, + override val reasonPhrase: String? + ) : HttpError + + companion object { + fun from(errorResponse: WebResourceResponse): HttpError { + return getError(errorResponse.statusCode, errorResponse.reasonPhrase) + } + + fun from(statusCode: Int): HttpError { + return getError(statusCode, null) + } + + private fun getError(statusCode: Int, reasonPhrase: String?): HttpError { + if (statusCode in 400..499) { + return ClientError::class.sealedSubclasses + .mapNotNull { it.objectInstance } + .firstOrNull { it.statusCode == statusCode } + ?: ClientError.Other(statusCode, reasonPhrase) + } + + if (statusCode in 500..599) { + return ServerError::class.sealedSubclasses + .map { it.objectInstance } + .firstOrNull { it?.statusCode == statusCode } + ?: ServerError.Other(statusCode, reasonPhrase) + } + + return UnknownError(statusCode, reasonPhrase) + } + } +} diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt new file mode 100644 index 00000000..07358dd7 --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt @@ -0,0 +1,17 @@ +package dev.hotwire.turbo.errors + +/** + * Errors representing when turbo.js or the native adapter fails + * to load on a page. + */ +sealed interface LoadError : TurboVisitError { + val description: String + + data object NotPresent : LoadError { + override val description = "Turbo Not Present" + } + + data object NotReady : LoadError { + override val description = "Turbo Not Ready" + } +} diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/TurboVisitError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/TurboVisitError.kt new file mode 100644 index 00000000..d6565618 --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/TurboVisitError.kt @@ -0,0 +1,6 @@ +package dev.hotwire.turbo.errors + +/** + * Represents all possible errors received when attempting to load a page. + */ +sealed interface TurboVisitError diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebError.kt new file mode 100644 index 00000000..36d4c7af --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebError.kt @@ -0,0 +1,130 @@ +package dev.hotwire.turbo.errors + +import androidx.webkit.WebResourceErrorCompat +import androidx.webkit.WebViewClientCompat +import androidx.webkit.WebViewFeature +import androidx.webkit.WebViewFeature.isFeatureSupported + +/** + * Errors representing WebViewClient.ERROR_* errors received + * from the WebView when attempting to load a page. + * https://developer.android.com/reference/android/webkit/WebViewClient + */ +sealed interface WebError : TurboVisitError { + val errorCode: Int + val description: String? + + data object Unknown : WebError { + override val errorCode = WebViewClientCompat.ERROR_UNKNOWN + override val description = "Unknown" + } + + data object HostLookup : WebError { + override val errorCode = WebViewClientCompat.ERROR_HOST_LOOKUP + override val description = "Host Lookup" + } + + data object UnsupportedAuthScheme : WebError { + override val errorCode = WebViewClientCompat.ERROR_UNSUPPORTED_AUTH_SCHEME + override val description = "Unsupported Auth Scheme" + } + + data object Authentication : WebError { + override val errorCode = WebViewClientCompat.ERROR_AUTHENTICATION + override val description = "Authentication" + } + + data object ProxyAuthentication : WebError { + override val errorCode = WebViewClientCompat.ERROR_PROXY_AUTHENTICATION + override val description = "Proxy Authentication" + } + + data object Connect : WebError { + override val errorCode = WebViewClientCompat.ERROR_CONNECT + override val description = "Connect" + } + + data object IO : WebError { + override val errorCode = WebViewClientCompat.ERROR_IO + override val description = "IO" + } + + data object Timeout : WebError { + override val errorCode = WebViewClientCompat.ERROR_TIMEOUT + override val description = "Timeout" + } + + data object RedirectLoop : WebError { + override val errorCode = WebViewClientCompat.ERROR_REDIRECT_LOOP + override val description = "Redirect Loop" + } + + data object UnsupportedScheme : WebError { + override val errorCode = WebViewClientCompat.ERROR_UNSUPPORTED_SCHEME + override val description = "Unsupported Scheme" + } + + data object FailedSslHandshake : WebError { + override val errorCode = WebViewClientCompat.ERROR_FAILED_SSL_HANDSHAKE + override val description = "Failed SSL Handshake" + } + + data object BadUrl : WebError { + override val errorCode = WebViewClientCompat.ERROR_BAD_URL + override val description = "Bad URL" + } + + data object File : WebError { + override val errorCode = WebViewClientCompat.ERROR_FILE + override val description = "File" + } + + data object FileNotFound : WebError { + override val errorCode = WebViewClientCompat.ERROR_FILE_NOT_FOUND + override val description = "File Not Found" + } + + data object TooManyRequests : WebError { + override val errorCode = WebViewClientCompat.ERROR_TOO_MANY_REQUESTS + override val description = "Too Many Requests" + } + + data object UnsafeResource : WebError { + override val errorCode = WebViewClientCompat.ERROR_UNSAFE_RESOURCE + override val description = "Unsafe Resource" + } + + data class Other( + override val errorCode: Int, + override val description: String? + ) : WebError + + companion object { + fun from(error: WebResourceErrorCompat): WebError { + val errorCode = if (isFeatureSupported(WebViewFeature.WEB_RESOURCE_ERROR_GET_CODE)) { + error.errorCode + } else { + 0 + } + + val description = if (isFeatureSupported(WebViewFeature.WEB_RESOURCE_ERROR_GET_DESCRIPTION)) { + error.description.toString() + } else { + null + } + + return getError(errorCode, description) + } + + fun from(errorCode: Int): WebError { + return getError(errorCode, null) + } + + private fun getError(errorCode: Int, description: String?): WebError { + return WebError::class.sealedSubclasses + .mapNotNull { it.objectInstance } + .firstOrNull { it.errorCode == errorCode } + ?: Other(errorCode, description) + } + } +} diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebSslError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebSslError.kt new file mode 100644 index 00000000..2da75bc2 --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebSslError.kt @@ -0,0 +1,64 @@ +package dev.hotwire.turbo.errors + +import android.net.http.SslError + +/** + * Errors representing SslError.SSL_* errors received + * from the WebView when attempting to load a page. + * https://developer.android.com/reference/android/net/http/SslError + */ +sealed interface WebSslError : TurboVisitError { + val errorCode: Int + val description: String? + + data object NotYetValid : WebSslError { + override val errorCode = SslError.SSL_NOTYETVALID + override val description = "Not Yet Valid" + } + + data object Expired : WebSslError { + override val errorCode = SslError.SSL_EXPIRED + override val description = "Expired" + } + + data object IdMismatch : WebSslError { + override val errorCode = SslError.SSL_IDMISMATCH + override val description = "ID Mismatch" + } + + data object Untrusted : WebSslError { + override val errorCode = SslError.SSL_UNTRUSTED + override val description = "Untrusted" + } + + data object DateInvalid : WebSslError { + override val errorCode = SslError.SSL_DATE_INVALID + override val description = "Date Invalid" + } + + data object Invalid : WebSslError { + override val errorCode = SslError.SSL_INVALID + override val description = "Invalid" + } + + data class Other(override val errorCode: Int) : WebSslError { + override val description = null + } + + companion object { + fun from(error: SslError): WebSslError { + return getError(error.primaryError) + } + + fun from(errorCode: Int): WebSslError { + return getError(errorCode) + } + + private fun getError(errorCode: Int): WebSslError { + return WebSslError::class.sealedSubclasses + .mapNotNull { it.objectInstance } + .firstOrNull { it.errorCode == errorCode } + ?: Other(errorCode) + } + } +} diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebBottomSheetDialogFragment.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebBottomSheetDialogFragment.kt index 741812a3..d7c37811 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebBottomSheetDialogFragment.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebBottomSheetDialogFragment.kt @@ -13,6 +13,7 @@ import dev.hotwire.turbo.delegates.TurboWebFragmentDelegate import dev.hotwire.turbo.util.TURBO_REQUEST_CODE_FILES import dev.hotwire.turbo.views.TurboView import dev.hotwire.turbo.views.TurboWebChromeClient +import dev.hotwire.turbo.errors.TurboVisitError /** * The base class from which all bottom sheet web fragments in a @@ -82,7 +83,7 @@ abstract class TurboWebBottomSheetDialogFragment : TurboBottomSheetDialogFragmen } @SuppressLint("InflateParams") - override fun createErrorView(statusCode: Int): View { + override fun createErrorView(error: TurboVisitError): View { return layoutInflater.inflate(R.layout.turbo_error, null) } @@ -90,7 +91,7 @@ abstract class TurboWebBottomSheetDialogFragment : TurboBottomSheetDialogFragmen return TurboWebChromeClient(session) } - override fun onVisitErrorReceived(location: String, errorCode: Int) { - webDelegate.showErrorView(errorCode) + override fun onVisitErrorReceived(location: String, error: TurboVisitError) { + webDelegate.showErrorView(error) } } diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragment.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragment.kt index 79549a35..28403616 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragment.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragment.kt @@ -13,6 +13,7 @@ import dev.hotwire.turbo.session.TurboSessionModalResult import dev.hotwire.turbo.util.TURBO_REQUEST_CODE_FILES import dev.hotwire.turbo.views.TurboView import dev.hotwire.turbo.views.TurboWebChromeClient +import dev.hotwire.turbo.errors.TurboVisitError /** * The base class from which all web "standard" fragments (non-dialogs) in a @@ -94,7 +95,7 @@ abstract class TurboWebFragment : TurboFragment(), TurboWebFragmentCallback { } @SuppressLint("InflateParams") - override fun createErrorView(statusCode: Int): View { + override fun createErrorView(error: TurboVisitError): View { return layoutInflater.inflate(R.layout.turbo_error, null) } @@ -102,7 +103,7 @@ abstract class TurboWebFragment : TurboFragment(), TurboWebFragmentCallback { return TurboWebChromeClient(session) } - override fun onVisitErrorReceived(location: String, errorCode: Int) { - webDelegate.showErrorView(errorCode) + override fun onVisitErrorReceived(location: String, error: TurboVisitError) { + webDelegate.showErrorView(error) } } diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragmentCallback.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragmentCallback.kt index ecb93ccd..26ca5058 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragmentCallback.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragmentCallback.kt @@ -5,6 +5,7 @@ import android.webkit.HttpAuthHandler import dev.hotwire.turbo.views.TurboView import dev.hotwire.turbo.views.TurboWebChromeClient import dev.hotwire.turbo.views.TurboWebView +import dev.hotwire.turbo.errors.TurboVisitError /** * Callback interface to be implemented by a [TurboWebFragment], @@ -19,7 +20,7 @@ interface TurboWebFragmentCallback { /** * Inflate and return a new view to serve as an error view. */ - fun createErrorView(statusCode: Int): View + fun createErrorView(error: TurboVisitError): View /** * Inflate and return a new view to serve as a progress view. @@ -71,7 +72,7 @@ interface TurboWebFragmentCallback { /** * Called when a Turbo visit resulted in an error. */ - fun onVisitErrorReceived(location: String, errorCode: Int) {} + fun onVisitErrorReceived(location: String, error: TurboVisitError) {} /** * Called when a Turbo form submission has started. @@ -87,7 +88,7 @@ interface TurboWebFragmentCallback { * Called when the Turbo visit resulted in an error, but a cached * snapshot is being displayed, which may be stale. */ - fun onVisitErrorReceivedWithCachedSnapshotAvailable(location: String, errorCode: Int) {} + fun onVisitErrorReceivedWithCachedSnapshotAvailable(location: String, error: TurboVisitError) {} /** * Called when the WebView has received an HTTP authentication request. diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt index 9d68cc5e..77a8b3c4 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt @@ -1,11 +1,9 @@ package dev.hotwire.turbo.session import android.annotation.SuppressLint -import android.annotation.TargetApi import android.content.Context import android.graphics.Bitmap import android.net.http.SslError -import android.os.Build import android.util.SparseArray import android.webkit.* import androidx.appcompat.app.AppCompatActivity @@ -17,6 +15,10 @@ import androidx.webkit.WebViewFeature.* import dev.hotwire.turbo.config.TurboPathConfiguration import dev.hotwire.turbo.config.screenshotsEnabled import dev.hotwire.turbo.delegates.TurboFileChooserDelegate +import dev.hotwire.turbo.errors.HttpError +import dev.hotwire.turbo.errors.LoadError +import dev.hotwire.turbo.errors.WebError +import dev.hotwire.turbo.errors.WebSslError import dev.hotwire.turbo.http.* import dev.hotwire.turbo.nav.TurboNavDestination import dev.hotwire.turbo.util.* @@ -283,16 +285,18 @@ class TurboSession internal constructor( */ @JavascriptInterface fun visitRequestFailedWithStatusCode(visitIdentifier: String, visitHasCachedSnapshot: Boolean, statusCode: Int) { + val visitError = HttpError.from(statusCode) + logEvent( "visitRequestFailedWithStatusCode", "visitIdentifier" to visitIdentifier, "visitHasCachedSnapshot" to visitHasCachedSnapshot, - "statusCode" to statusCode + "error" to visitError ) currentVisit?.let { visit -> if (visitIdentifier == visit.identifier) { - callback { it.requestFailedWithStatusCode(visitHasCachedSnapshot, statusCode) } + callback { it.requestFailedWithError(visitHasCachedSnapshot, visitError) } } } } @@ -456,7 +460,11 @@ class TurboSession internal constructor( if (!isReady) { reset() - visitRequestFailedWithStatusCode(visit.identifier, false, 0) + + val visitError = LoadError.NotReady + logEvent("turboIsNotReady", "error" to visitError) + + callback { it.requestFailedWithError(false, visitError) } return } @@ -477,9 +485,11 @@ class TurboSession internal constructor( */ @JavascriptInterface fun turboFailedToLoad() { - logEvent("turboFailedToLoad") + val visitError = LoadError.NotPresent + + logEvent("turboFailedToLoad", "error" to visitError) reset() - callback { it.onReceivedError(-1) } + callback { it.onReceivedError(visitError) } } /** @@ -747,10 +757,12 @@ class TurboSession internal constructor( override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceErrorCompat) { super.onReceivedError(view, request, error) - if (request.isForMainFrame && isFeatureSupported(WEB_RESOURCE_ERROR_GET_CODE)) { - logEvent("onReceivedError", "errorCode" to error.errorCode) + if (request.isForMainFrame) { + val visitError = WebError.from(error) + + logEvent("onReceivedError", "error" to visitError) reset() - callback { it.onReceivedError(error.errorCode) } + callback { it.onReceivedError(visitError) } } } @@ -758,9 +770,11 @@ class TurboSession internal constructor( super.onReceivedHttpError(view, request, errorResponse) if (request.isForMainFrame) { - logEvent("onReceivedHttpError", "statusCode" to errorResponse.statusCode) + val visitError = HttpError.from(errorResponse) + + logEvent("onReceivedHttpError", "error" to visitError) reset() - callback { it.onReceivedError(errorResponse.statusCode) } + callback { it.onReceivedError(visitError) } } } @@ -768,12 +782,13 @@ class TurboSession internal constructor( super.onReceivedSslError(view, handler, error) handler.cancel() - logEvent("onReceivedSslError", "url" to error.url) + val visitError = WebSslError.from(error) + + logEvent("onReceivedSslError", "error" to visitError) reset() - callback { it.onReceivedError(-1) } + callback { it.onReceivedError(visitError) } } - @TargetApi(Build.VERSION_CODES.O) override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { logEvent("onRenderProcessGone", "didCrash" to detail.didCrash()) diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSessionCallback.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSessionCallback.kt index c3940d81..0bbaa01e 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSessionCallback.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSessionCallback.kt @@ -2,17 +2,18 @@ package dev.hotwire.turbo.session import android.webkit.HttpAuthHandler import dev.hotwire.turbo.nav.TurboNavDestination +import dev.hotwire.turbo.errors.TurboVisitError import dev.hotwire.turbo.visit.TurboVisitOptions internal interface TurboSessionCallback { fun onPageStarted(location: String) fun onPageFinished(location: String) - fun onReceivedError(errorCode: Int) + fun onReceivedError(error: TurboVisitError) fun onRenderProcessGone() fun onZoomed(newScale: Float) fun onZoomReset(newScale: Float) fun pageInvalidated() - fun requestFailedWithStatusCode(visitHasCachedSnapshot: Boolean, statusCode: Int) + fun requestFailedWithError(visitHasCachedSnapshot: Boolean, error: TurboVisitError) fun onReceivedHttpAuthRequest(handler: HttpAuthHandler, host: String, realm: String) fun visitRendered() fun visitCompleted(completedOffline: Boolean) diff --git a/turbo/src/test/kotlin/dev/hotwire/turbo/errors/HttpErrorTest.kt b/turbo/src/test/kotlin/dev/hotwire/turbo/errors/HttpErrorTest.kt new file mode 100644 index 00000000..48b680aa --- /dev/null +++ b/turbo/src/test/kotlin/dev/hotwire/turbo/errors/HttpErrorTest.kt @@ -0,0 +1,76 @@ +package dev.hotwire.turbo.errors + +import android.os.Build +import dev.hotwire.turbo.BaseUnitTest +import dev.hotwire.turbo.errors.HttpError.ClientError +import dev.hotwire.turbo.errors.HttpError.ServerError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O]) +class HttpErrorTest : BaseUnitTest() { + @Test + fun clientErrors() { + val errors = listOf( + 400 to ClientError.BadRequest, + 401 to ClientError.Unauthorized, + 403 to ClientError.Forbidden, + 404 to ClientError.NotFound, + 405 to ClientError.MethodNotAllowed, + 406 to ClientError.NotAccessible, + 407 to ClientError.ProxyAuthenticationRequired, + 408 to ClientError.RequestTimeout, + 409 to ClientError.Conflict, + 421 to ClientError.MisdirectedRequest, + 422 to ClientError.UnprocessableEntity, + 428 to ClientError.PreconditionRequired, + 429 to ClientError.TooManyRequests, + 430 to ClientError.Other(430, null), + 499 to ClientError.Other(499, null) + ) + + errors.forEach { + val error = HttpError.from(it.first) + assertThat(error).isEqualTo(it.second) + assertThat(error.statusCode).isEqualTo(it.first) + } + } + + @Test + fun serverErrors() { + val errors = listOf( + 500 to ServerError.InternalServerError, + 501 to ServerError.NotImplemented, + 502 to ServerError.BadGateway, + 503 to ServerError.ServiceUnavailable, + 504 to ServerError.GatewayTimeout, + 505 to ServerError.HttpVersionNotSupported, + 506 to ServerError.Other(506, null), + 599 to ServerError.Other(599, null) + ) + + errors.forEach { + val error = HttpError.from(it.first) + assertThat(error).isEqualTo(it.second) + assertThat(error.statusCode).isEqualTo(it.first) + } + } + + @Test + fun unknownErrors() { + val errors = listOf( + 399 to HttpError.UnknownError(399, null), + 600 to HttpError.UnknownError(600, null) + ) + + errors.forEach { + val error = HttpError.from(it.first) + assertThat(error).isEqualTo(it.second) + assertThat(error.statusCode).isEqualTo(it.first) + } + } +} diff --git a/turbo/src/test/kotlin/dev/hotwire/turbo/errors/WebErrorTest.kt b/turbo/src/test/kotlin/dev/hotwire/turbo/errors/WebErrorTest.kt new file mode 100644 index 00000000..984daa72 --- /dev/null +++ b/turbo/src/test/kotlin/dev/hotwire/turbo/errors/WebErrorTest.kt @@ -0,0 +1,44 @@ +package dev.hotwire.turbo.errors + +import android.os.Build +import androidx.webkit.WebViewClientCompat +import dev.hotwire.turbo.BaseUnitTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O]) +class WebErrorTest : BaseUnitTest() { + @Test + fun webErrors() { + val errors = listOf( + WebViewClientCompat.ERROR_UNKNOWN to WebError.Unknown, + WebViewClientCompat.ERROR_HOST_LOOKUP to WebError.HostLookup, + WebViewClientCompat.ERROR_UNSUPPORTED_AUTH_SCHEME to WebError.UnsupportedAuthScheme, + WebViewClientCompat.ERROR_AUTHENTICATION to WebError.Authentication, + WebViewClientCompat.ERROR_PROXY_AUTHENTICATION to WebError.ProxyAuthentication, + WebViewClientCompat.ERROR_CONNECT to WebError.Connect, + WebViewClientCompat.ERROR_IO to WebError.IO, + WebViewClientCompat.ERROR_TIMEOUT to WebError.Timeout, + WebViewClientCompat.ERROR_REDIRECT_LOOP to WebError.RedirectLoop, + WebViewClientCompat.ERROR_UNSUPPORTED_SCHEME to WebError.UnsupportedScheme, + WebViewClientCompat.ERROR_FAILED_SSL_HANDSHAKE to WebError.FailedSslHandshake, + WebViewClientCompat.ERROR_BAD_URL to WebError.BadUrl, + WebViewClientCompat.ERROR_FILE to WebError.File, + WebViewClientCompat.ERROR_FILE_NOT_FOUND to WebError.FileNotFound, + WebViewClientCompat.ERROR_TOO_MANY_REQUESTS to WebError.TooManyRequests, + WebViewClientCompat.ERROR_UNSAFE_RESOURCE to WebError.UnsafeResource, + -17 to WebError.Other(-17, null), + 1 to WebError.Other(1, null), + ) + + errors.forEach { + val error = WebError.from(it.first) + assertThat(error).isEqualTo(it.second) + assertThat(error.errorCode).isEqualTo(it.first) + } + } +} diff --git a/turbo/src/test/kotlin/dev/hotwire/turbo/errors/WebSslErrorTest.kt b/turbo/src/test/kotlin/dev/hotwire/turbo/errors/WebSslErrorTest.kt new file mode 100644 index 00000000..72010fe8 --- /dev/null +++ b/turbo/src/test/kotlin/dev/hotwire/turbo/errors/WebSslErrorTest.kt @@ -0,0 +1,34 @@ +package dev.hotwire.turbo.errors + +import android.net.http.SslError +import android.os.Build +import dev.hotwire.turbo.BaseUnitTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O]) +class WebSslErrorTest : BaseUnitTest() { + @Test + fun sslErrors() { + val errors = listOf( + SslError.SSL_NOTYETVALID to WebSslError.NotYetValid, + SslError.SSL_EXPIRED to WebSslError.Expired, + SslError.SSL_IDMISMATCH to WebSslError.IdMismatch, + SslError.SSL_UNTRUSTED to WebSslError.Untrusted, + SslError.SSL_DATE_INVALID to WebSslError.DateInvalid, + SslError.SSL_INVALID to WebSslError.Invalid, + -1 to WebSslError.Other(-1), + 6 to WebSslError.Other(6), + ) + + errors.forEach { + val error = WebSslError.from(it.first) + assertThat(error).isEqualTo(it.second) + assertThat(error.errorCode).isEqualTo(it.first) + } + } +} diff --git a/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt b/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt index ce7a1b83..86ce02df 100644 --- a/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt +++ b/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt @@ -5,6 +5,8 @@ import androidx.appcompat.app.AppCompatActivity import com.nhaarman.mockito_kotlin.never import com.nhaarman.mockito_kotlin.times import com.nhaarman.mockito_kotlin.whenever +import dev.hotwire.turbo.errors.HttpError.ServerError +import dev.hotwire.turbo.errors.LoadError import dev.hotwire.turbo.nav.TurboNavDestination import dev.hotwire.turbo.util.toJson import dev.hotwire.turbo.views.TurboWebView @@ -88,6 +90,16 @@ class TurboSessionTest { assertThat(session.currentVisit?.identifier).isEqualTo(visitIdentifier) } + @Test + fun visitFailedToLoadCallsAdapter() { + val visitIdentifier = "12345" + + session.currentVisit = visit.copy(identifier = visitIdentifier) + session.turboFailedToLoad() + + verify(callback).onReceivedError(LoadError.NotPresent) + } + @Test fun visitRequestFailedWithStatusCodeCallsAdapter() { val visitIdentifier = "12345" @@ -95,7 +107,10 @@ class TurboSessionTest { session.currentVisit = visit.copy(identifier = visitIdentifier) session.visitRequestFailedWithStatusCode(visitIdentifier, true, 500) - verify(callback).requestFailedWithStatusCode(true, 500) + verify(callback).requestFailedWithError( + visitHasCachedSnapshot = true, + error = ServerError.InternalServerError + ) } @Test @@ -214,6 +229,7 @@ class TurboSessionTest { assertThat(session.restoreCurrentVisit(callback)).isFalse() verify(callback, never()).visitCompleted(false) + verify(callback).requestFailedWithError(false, LoadError.NotReady) } @Test