From b07ccbc0135aa5bf9cea70b6378280b545a4c1d3 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Mon, 26 Feb 2024 12:22:32 -0500 Subject: [PATCH 1/7] Create a new TurboVisitError and TurboVisitErrorType to capture visit error information and pass to apps --- .../turbo/demo/features/web/WebFragment.kt | 7 +-- .../demo/features/web/WebHomeFragment.kt | 3 +- .../delegates/TurboWebFragmentDelegate.kt | 15 +++--- .../TurboWebBottomSheetDialogFragment.kt | 7 +-- .../turbo/fragments/TurboWebFragment.kt | 7 +-- .../fragments/TurboWebFragmentCallback.kt | 7 +-- .../dev/hotwire/turbo/session/TurboSession.kt | 54 +++++++++++++++---- .../turbo/session/TurboSessionCallback.kt | 6 ++- .../hotwire/turbo/visit/TurboVisitError.kt | 18 +++++++ .../turbo/visit/TurboVisitErrorType.kt | 23 ++++++++ .../hotwire/turbo/session/TurboSessionTest.kt | 22 +++++++- 11 files changed, 136 insertions(+), 33 deletions(-) create mode 100644 turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt create mode 100644 turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt 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..2d124aa6 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 @@ -11,6 +11,7 @@ import dev.hotwire.turbo.fragments.TurboWebFragment import dev.hotwire.turbo.nav.TurboNavGraphDestination import dev.hotwire.turbo.views.TurboWebView import dev.hotwire.turbo.visit.TurboVisitAction.REPLACE +import dev.hotwire.turbo.visit.TurboVisitError import dev.hotwire.turbo.visit.TurboVisitOptions @TurboNavGraphDestination(uri = "turbo://fragment/web") @@ -58,10 +59,10 @@ open class WebFragment : TurboWebFragment(), NavDestination { menuProgress?.isVisible = false } - override fun onVisitErrorReceived(location: String, errorCode: Int) { - when (errorCode) { + override fun onVisitErrorReceived(location: String, error: TurboVisitError) { + when (error.code) { 401 -> navigate(SIGN_IN_URL, TurboVisitOptions(action = REPLACE)) - else -> super.onVisitErrorReceived(location, errorCode) + 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..b40d577f 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.visit.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..9591f25f 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.visit.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/fragments/TurboWebBottomSheetDialogFragment.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebBottomSheetDialogFragment.kt index 741812a3..d2f35af1 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.visit.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..7b36d7cd 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.visit.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..64458dd8 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.visit.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..b4df2c6b 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt @@ -23,6 +23,8 @@ import dev.hotwire.turbo.util.* import dev.hotwire.turbo.views.TurboWebView import dev.hotwire.turbo.visit.TurboVisit import dev.hotwire.turbo.visit.TurboVisitAction +import dev.hotwire.turbo.visit.TurboVisitError +import dev.hotwire.turbo.visit.TurboVisitErrorType import dev.hotwire.turbo.visit.TurboVisitOptions import kotlinx.coroutines.* import java.util.* @@ -283,6 +285,12 @@ class TurboSession internal constructor( */ @JavascriptInterface fun visitRequestFailedWithStatusCode(visitIdentifier: String, visitHasCachedSnapshot: Boolean, statusCode: Int) { + val visitError = TurboVisitError( + type = TurboVisitErrorType.HTTP_ERROR, + code = statusCode, + description = "Request failed" + ) + logEvent( "visitRequestFailedWithStatusCode", "visitIdentifier" to visitIdentifier, @@ -292,7 +300,7 @@ class TurboSession internal constructor( currentVisit?.let { visit -> if (visitIdentifier == visit.identifier) { - callback { it.requestFailedWithStatusCode(visitHasCachedSnapshot, statusCode) } + callback { it.requestFailedWithError(visitHasCachedSnapshot, visitError) } } } } @@ -477,9 +485,15 @@ class TurboSession internal constructor( */ @JavascriptInterface fun turboFailedToLoad() { - logEvent("turboFailedToLoad") + val visitError = TurboVisitError( + type = TurboVisitErrorType.LOAD_ERROR, + code = -1, + description = "Turbo failed to load" + ) + + logEvent("turboFailedToLoad", "error" to visitError) reset() - callback { it.onReceivedError(-1) } + callback { it.onReceivedError(visitError) } } /** @@ -748,9 +762,19 @@ class TurboSession internal constructor( super.onReceivedError(view, request, error) if (request.isForMainFrame && isFeatureSupported(WEB_RESOURCE_ERROR_GET_CODE)) { - logEvent("onReceivedError", "errorCode" to error.errorCode) + val visitError = TurboVisitError( + type = TurboVisitErrorType.WEB_RESOURCE_ERROR, + code = error.errorCode, + description = if (isFeatureSupported(WEB_RESOURCE_ERROR_GET_DESCRIPTION)) { + error.description.toString() + } else { + null + } + ) + + logEvent("onReceivedError", "error" to visitError) reset() - callback { it.onReceivedError(error.errorCode) } + callback { it.onReceivedError(visitError) } } } @@ -758,9 +782,15 @@ class TurboSession internal constructor( super.onReceivedHttpError(view, request, errorResponse) if (request.isForMainFrame) { - logEvent("onReceivedHttpError", "statusCode" to errorResponse.statusCode) + val visitError = TurboVisitError( + type = TurboVisitErrorType.HTTP_ERROR, + code = errorResponse.statusCode, + description = errorResponse.reasonPhrase + ) + + logEvent("onReceivedHttpError", "error" to visitError) reset() - callback { it.onReceivedError(errorResponse.statusCode) } + callback { it.onReceivedError(visitError) } } } @@ -768,12 +798,16 @@ class TurboSession internal constructor( super.onReceivedSslError(view, handler, error) handler.cancel() - logEvent("onReceivedSslError", "url" to error.url) + val visitError = TurboVisitError( + type = TurboVisitErrorType.WEB_SSL_ERROR, + code = error.primaryError + ) + + 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..5039600e 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,19 @@ package dev.hotwire.turbo.session import android.webkit.HttpAuthHandler import dev.hotwire.turbo.nav.TurboNavDestination +import dev.hotwire.turbo.visit.TurboVisitError +import dev.hotwire.turbo.visit.TurboVisitErrorType 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/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt new file mode 100644 index 00000000..b7fe7539 --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt @@ -0,0 +1,18 @@ +package dev.hotwire.turbo.visit + +data class TurboVisitError( + /** + * + */ + val type: TurboVisitErrorType, + + /** + * + */ + val code: Int, + + /** + * + */ + val description: String? = null +) diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt new file mode 100644 index 00000000..be03f125 --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt @@ -0,0 +1,23 @@ +package dev.hotwire.turbo.visit + +enum class TurboVisitErrorType { + /** + * + */ + LOAD_ERROR, + + /** + * + */ + HTTP_ERROR, + + /** + * + */ + WEB_RESOURCE_ERROR, + + /** + * + */ + WEB_SSL_ERROR +} 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..3ef66738 100644 --- a/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt +++ b/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt @@ -9,6 +9,8 @@ import dev.hotwire.turbo.nav.TurboNavDestination import dev.hotwire.turbo.util.toJson import dev.hotwire.turbo.views.TurboWebView import dev.hotwire.turbo.visit.TurboVisit +import dev.hotwire.turbo.visit.TurboVisitError +import dev.hotwire.turbo.visit.TurboVisitErrorType import dev.hotwire.turbo.visit.TurboVisitOptions import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -88,6 +90,20 @@ 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(TurboVisitError( + type = TurboVisitErrorType.LOAD_ERROR, + code = -1, + description = "Turbo failed to load" + )) + } + @Test fun visitRequestFailedWithStatusCodeCallsAdapter() { val visitIdentifier = "12345" @@ -95,7 +111,11 @@ class TurboSessionTest { session.currentVisit = visit.copy(identifier = visitIdentifier) session.visitRequestFailedWithStatusCode(visitIdentifier, true, 500) - verify(callback).requestFailedWithStatusCode(true, 500) + verify(callback).requestFailedWithError(true, TurboVisitError( + type = TurboVisitErrorType.HTTP_ERROR, + code = 500, + description = "Request failed" + )) } @Test From 11bf8738fffc693ca4a7d6143fcde10e83d13315 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Mon, 26 Feb 2024 12:56:20 -0500 Subject: [PATCH 2/7] Add API documentation for the error properties and types --- .../dev/hotwire/turbo/visit/TurboVisitError.kt | 6 +++--- .../dev/hotwire/turbo/visit/TurboVisitErrorType.kt | 14 ++++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt index b7fe7539..c0b44ce5 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt @@ -2,17 +2,17 @@ package dev.hotwire.turbo.visit data class TurboVisitError( /** - * + * The [TurboVisitErrorType] type of error received. */ val type: TurboVisitErrorType, /** - * + * The error code associated with the [TurboVisitErrorType] type. */ val code: Int, /** - * + * The (optional) description of the error. */ val description: String? = null ) diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt index be03f125..602a8fba 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt @@ -2,22 +2,28 @@ package dev.hotwire.turbo.visit enum class TurboVisitErrorType { /** - * + * Represents an error when Turbo and the javascript adapter fails to + * load on the page. */ LOAD_ERROR, /** - * + * Represents an error received from your server with an HTTP status code. + * The code corresponds to the status code received from your server. */ HTTP_ERROR, /** - * + * Represents an [androidx.webkit.WebResourceErrorCompat] error received + * from the WebView when attempting to load the page. The code corresponds + * to one of the ERROR_* constants in [androidx.webkit.WebViewClientCompat]. */ WEB_RESOURCE_ERROR, /** - * + * Represents an [android.net.http.SslError] error received from the WebView + * when attempting to load the page. The code corresponds to one of the + * SSL_* constants in [android.net.http.SslError]. */ WEB_SSL_ERROR } From a28816f4a1da0dbc570d50c51e6c7bc74c0f5b8e Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Tue, 27 Feb 2024 13:35:02 -0500 Subject: [PATCH 3/7] Use sealed interfaces to organize and enumerate all errors --- .../turbo/demo/features/web/WebFragment.kt | 10 +- .../demo/features/web/WebHomeFragment.kt | 2 +- .../delegates/TurboWebFragmentDelegate.kt | 2 +- .../dev/hotwire/turbo/errors/HttpError.kt | 145 ++++++++++++++++++ .../dev/hotwire/turbo/errors/LoadError.kt | 6 + .../hotwire/turbo/errors/TurboVisitError.kt | 3 + .../dev/hotwire/turbo/errors/WebError.kt | 125 +++++++++++++++ .../dev/hotwire/turbo/errors/WebSslError.kt | 59 +++++++ .../TurboWebBottomSheetDialogFragment.kt | 2 +- .../turbo/fragments/TurboWebFragment.kt | 2 +- .../fragments/TurboWebFragmentCallback.kt | 2 +- .../dev/hotwire/turbo/session/TurboSession.kt | 49 ++---- .../turbo/session/TurboSessionCallback.kt | 3 +- .../hotwire/turbo/visit/TurboVisitError.kt | 18 --- .../dev/hotwire/turbo/errors/HttpErrorTest.kt | 62 ++++++++ .../dev/hotwire/turbo/errors/WebErrorTest.kt | 44 ++++++ .../hotwire/turbo/errors/WebSslErrorTest.kt | 34 ++++ .../hotwire/turbo/session/TurboSessionTest.kt | 20 +-- 18 files changed, 513 insertions(+), 75 deletions(-) create mode 100644 turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt create mode 100644 turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt create mode 100644 turbo/src/main/kotlin/dev/hotwire/turbo/errors/TurboVisitError.kt create mode 100644 turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebError.kt create mode 100644 turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebSslError.kt delete mode 100644 turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt create mode 100644 turbo/src/test/kotlin/dev/hotwire/turbo/errors/HttpErrorTest.kt create mode 100644 turbo/src/test/kotlin/dev/hotwire/turbo/errors/WebErrorTest.kt create mode 100644 turbo/src/test/kotlin/dev/hotwire/turbo/errors/WebSslErrorTest.kt 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 2d124aa6..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,8 +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.visit.TurboVisitError +import dev.hotwire.turbo.errors.TurboVisitError import dev.hotwire.turbo.visit.TurboVisitOptions @TurboNavGraphDestination(uri = "turbo://fragment/web") @@ -60,9 +61,10 @@ open class WebFragment : TurboWebFragment(), NavDestination { } override fun onVisitErrorReceived(location: String, error: TurboVisitError) { - when (error.code) { - 401 -> navigate(SIGN_IN_URL, TurboVisitOptions(action = REPLACE)) - else -> super.onVisitErrorReceived(location, error) + 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 b40d577f..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,7 +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.visit.TurboVisitError +import dev.hotwire.turbo.errors.TurboVisitError @TurboNavGraphDestination(uri = "turbo://fragment/web/home") class WebHomeFragment : WebFragment() { 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 9591f25f..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,7 +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.visit.TurboVisitError +import dev.hotwire.turbo.errors.TurboVisitError import dev.hotwire.turbo.visit.TurboVisitOptions import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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..5fdf8956 --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt @@ -0,0 +1,145 @@ +package dev.hotwire.turbo.errors + +import android.webkit.WebResourceResponse + +sealed interface HttpError : TurboVisitError { + val statusCode: Int + val reasonPhrase: String? + + 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 + } + + 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 + } + + 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) + } + + throw IllegalArgumentException("Invalid HTTP error status code: $statusCode") + } + } +} 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..73fc829c --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt @@ -0,0 +1,6 @@ +package dev.hotwire.turbo.errors + +sealed interface LoadError : TurboVisitError { + data object NotPresent : LoadError + data object NotReady : LoadError +} 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..c84ee42b --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/TurboVisitError.kt @@ -0,0 +1,3 @@ +package dev.hotwire.turbo.errors + +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..dc8ba5e8 --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebError.kt @@ -0,0 +1,125 @@ +package dev.hotwire.turbo.errors + +import androidx.webkit.WebResourceErrorCompat +import androidx.webkit.WebViewClientCompat +import androidx.webkit.WebViewFeature +import androidx.webkit.WebViewFeature.isFeatureSupported + +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..28d1d113 --- /dev/null +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebSslError.kt @@ -0,0 +1,59 @@ +package dev.hotwire.turbo.errors + +import 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 d2f35af1..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,7 +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.visit.TurboVisitError +import dev.hotwire.turbo.errors.TurboVisitError /** * The base class from which all bottom sheet web fragments in a 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 7b36d7cd..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,7 +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.visit.TurboVisitError +import dev.hotwire.turbo.errors.TurboVisitError /** * The base class from which all web "standard" fragments (non-dialogs) in a 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 64458dd8..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,7 +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.visit.TurboVisitError +import dev.hotwire.turbo.errors.TurboVisitError /** * Callback interface to be implemented by a [TurboWebFragment], 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 b4df2c6b..04af0d21 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,14 +15,16 @@ 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.* import dev.hotwire.turbo.views.TurboWebView import dev.hotwire.turbo.visit.TurboVisit import dev.hotwire.turbo.visit.TurboVisitAction -import dev.hotwire.turbo.visit.TurboVisitError -import dev.hotwire.turbo.visit.TurboVisitErrorType import dev.hotwire.turbo.visit.TurboVisitOptions import kotlinx.coroutines.* import java.util.* @@ -285,11 +285,7 @@ class TurboSession internal constructor( */ @JavascriptInterface fun visitRequestFailedWithStatusCode(visitIdentifier: String, visitHasCachedSnapshot: Boolean, statusCode: Int) { - val visitError = TurboVisitError( - type = TurboVisitErrorType.HTTP_ERROR, - code = statusCode, - description = "Request failed" - ) + val visitError = HttpError.from(statusCode) logEvent( "visitRequestFailedWithStatusCode", @@ -464,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 } @@ -485,11 +485,7 @@ class TurboSession internal constructor( */ @JavascriptInterface fun turboFailedToLoad() { - val visitError = TurboVisitError( - type = TurboVisitErrorType.LOAD_ERROR, - code = -1, - description = "Turbo failed to load" - ) + val visitError = LoadError.NotPresent logEvent("turboFailedToLoad", "error" to visitError) reset() @@ -761,16 +757,8 @@ 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)) { - val visitError = TurboVisitError( - type = TurboVisitErrorType.WEB_RESOURCE_ERROR, - code = error.errorCode, - description = if (isFeatureSupported(WEB_RESOURCE_ERROR_GET_DESCRIPTION)) { - error.description.toString() - } else { - null - } - ) + if (request.isForMainFrame) { + val visitError = WebError.from(error) logEvent("onReceivedError", "error" to visitError) reset() @@ -782,11 +770,7 @@ class TurboSession internal constructor( super.onReceivedHttpError(view, request, errorResponse) if (request.isForMainFrame) { - val visitError = TurboVisitError( - type = TurboVisitErrorType.HTTP_ERROR, - code = errorResponse.statusCode, - description = errorResponse.reasonPhrase - ) + val visitError = HttpError.from(errorResponse) logEvent("onReceivedHttpError", "error" to visitError) reset() @@ -798,10 +782,7 @@ class TurboSession internal constructor( super.onReceivedSslError(view, handler, error) handler.cancel() - val visitError = TurboVisitError( - type = TurboVisitErrorType.WEB_SSL_ERROR, - code = error.primaryError - ) + val visitError = WebSslError.from(error) logEvent("onReceivedSslError", "error" to visitError) reset() 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 5039600e..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,8 +2,7 @@ package dev.hotwire.turbo.session import android.webkit.HttpAuthHandler import dev.hotwire.turbo.nav.TurboNavDestination -import dev.hotwire.turbo.visit.TurboVisitError -import dev.hotwire.turbo.visit.TurboVisitErrorType +import dev.hotwire.turbo.errors.TurboVisitError import dev.hotwire.turbo.visit.TurboVisitOptions internal interface TurboSessionCallback { diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt deleted file mode 100644 index c0b44ce5..00000000 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitError.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.hotwire.turbo.visit - -data class TurboVisitError( - /** - * The [TurboVisitErrorType] type of error received. - */ - val type: TurboVisitErrorType, - - /** - * The error code associated with the [TurboVisitErrorType] type. - */ - val code: Int, - - /** - * The (optional) description of the error. - */ - val description: String? = null -) 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..7741a647 --- /dev/null +++ b/turbo/src/test/kotlin/dev/hotwire/turbo/errors/HttpErrorTest.kt @@ -0,0 +1,62 @@ +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) + } + } +} 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 3ef66738..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,12 +5,12 @@ 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 import dev.hotwire.turbo.visit.TurboVisit -import dev.hotwire.turbo.visit.TurboVisitError -import dev.hotwire.turbo.visit.TurboVisitErrorType import dev.hotwire.turbo.visit.TurboVisitOptions import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -97,11 +97,7 @@ class TurboSessionTest { session.currentVisit = visit.copy(identifier = visitIdentifier) session.turboFailedToLoad() - verify(callback).onReceivedError(TurboVisitError( - type = TurboVisitErrorType.LOAD_ERROR, - code = -1, - description = "Turbo failed to load" - )) + verify(callback).onReceivedError(LoadError.NotPresent) } @Test @@ -111,11 +107,10 @@ class TurboSessionTest { session.currentVisit = visit.copy(identifier = visitIdentifier) session.visitRequestFailedWithStatusCode(visitIdentifier, true, 500) - verify(callback).requestFailedWithError(true, TurboVisitError( - type = TurboVisitErrorType.HTTP_ERROR, - code = 500, - description = "Request failed" - )) + verify(callback).requestFailedWithError( + visitHasCachedSnapshot = true, + error = ServerError.InternalServerError + ) } @Test @@ -234,6 +229,7 @@ class TurboSessionTest { assertThat(session.restoreCurrentVisit(callback)).isFalse() verify(callback, never()).visitCompleted(false) + verify(callback).requestFailedWithError(false, LoadError.NotReady) } @Test From b7b1e5464517c5043c719e3b7e3005545d99d7a7 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Tue, 27 Feb 2024 13:48:33 -0500 Subject: [PATCH 4/7] Cleanup and add descriptions to Turbo load errors --- .../main/kotlin/dev/hotwire/turbo/errors/LoadError.kt | 11 +++++++++-- .../kotlin/dev/hotwire/turbo/session/TurboSession.kt | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt index 73fc829c..4c32143a 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt @@ -1,6 +1,13 @@ package dev.hotwire.turbo.errors sealed interface LoadError : TurboVisitError { - data object NotPresent : LoadError - data object NotReady : LoadError + 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/session/TurboSession.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt index 04af0d21..77a8b3c4 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt @@ -291,7 +291,7 @@ class TurboSession internal constructor( "visitRequestFailedWithStatusCode", "visitIdentifier" to visitIdentifier, "visitHasCachedSnapshot" to visitHasCachedSnapshot, - "statusCode" to statusCode + "error" to visitError ) currentVisit?.let { visit -> From 45d543be8e8a232384f63c23b67e0890c145f547 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Tue, 27 Feb 2024 14:35:59 -0500 Subject: [PATCH 5/7] Add API docs to the top-level error types --- .../main/kotlin/dev/hotwire/turbo/errors/HttpError.kt | 9 +++++++++ .../main/kotlin/dev/hotwire/turbo/errors/LoadError.kt | 4 ++++ .../kotlin/dev/hotwire/turbo/errors/TurboVisitError.kt | 3 +++ .../src/main/kotlin/dev/hotwire/turbo/errors/WebError.kt | 5 +++++ .../main/kotlin/dev/hotwire/turbo/errors/WebSslError.kt | 5 +++++ 5 files changed, 26 insertions(+) diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt index 5fdf8956..dcc7b1b5 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt @@ -2,10 +2,16 @@ 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 @@ -78,6 +84,9 @@ sealed interface HttpError : TurboVisitError { ) : ClientError } + /** + * Errors representing HTTP server errors in the 500..599 range. + */ sealed interface ServerError : HttpError { data object InternalServerError : ServerError { override val statusCode = 500 diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt index 4c32143a..07358dd7 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/LoadError.kt @@ -1,5 +1,9 @@ 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 diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/TurboVisitError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/TurboVisitError.kt index c84ee42b..d6565618 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/TurboVisitError.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/TurboVisitError.kt @@ -1,3 +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 index dc8ba5e8..36d4c7af 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebError.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebError.kt @@ -5,6 +5,11 @@ 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? diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebSslError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebSslError.kt index 28d1d113..2da75bc2 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebSslError.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/WebSslError.kt @@ -2,6 +2,11 @@ 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? From b75dd26df8ad5e1032e4985102e87dc9941e50cc Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 28 Feb 2024 08:35:44 -0500 Subject: [PATCH 6/7] Delete no longer needed error type --- .../turbo/visit/TurboVisitErrorType.kt | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt deleted file mode 100644 index 602a8fba..00000000 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/visit/TurboVisitErrorType.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.hotwire.turbo.visit - -enum class TurboVisitErrorType { - /** - * Represents an error when Turbo and the javascript adapter fails to - * load on the page. - */ - LOAD_ERROR, - - /** - * Represents an error received from your server with an HTTP status code. - * The code corresponds to the status code received from your server. - */ - HTTP_ERROR, - - /** - * Represents an [androidx.webkit.WebResourceErrorCompat] error received - * from the WebView when attempting to load the page. The code corresponds - * to one of the ERROR_* constants in [androidx.webkit.WebViewClientCompat]. - */ - WEB_RESOURCE_ERROR, - - /** - * Represents an [android.net.http.SslError] error received from the WebView - * when attempting to load the page. The code corresponds to one of the - * SSL_* constants in [android.net.http.SslError]. - */ - WEB_SSL_ERROR -} From cc4cde3dd2ebe58b4c044bbfea6a901143006485 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Wed, 28 Feb 2024 10:10:32 -0500 Subject: [PATCH 7/7] Add a new HttpError.UnknownError type instead of throwing an exception --- .../kotlin/dev/hotwire/turbo/errors/HttpError.kt | 7 ++++++- .../dev/hotwire/turbo/errors/HttpErrorTest.kt | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt index dcc7b1b5..1d56d669 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/errors/HttpError.kt @@ -124,6 +124,11 @@ sealed interface HttpError : TurboVisitError { ) : 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) @@ -148,7 +153,7 @@ sealed interface HttpError : TurboVisitError { ?: ServerError.Other(statusCode, reasonPhrase) } - throw IllegalArgumentException("Invalid HTTP error status code: $statusCode") + return UnknownError(statusCode, reasonPhrase) } } } diff --git a/turbo/src/test/kotlin/dev/hotwire/turbo/errors/HttpErrorTest.kt b/turbo/src/test/kotlin/dev/hotwire/turbo/errors/HttpErrorTest.kt index 7741a647..48b680aa 100644 --- a/turbo/src/test/kotlin/dev/hotwire/turbo/errors/HttpErrorTest.kt +++ b/turbo/src/test/kotlin/dev/hotwire/turbo/errors/HttpErrorTest.kt @@ -59,4 +59,18 @@ class HttpErrorTest : BaseUnitTest() { 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) + } + } }