diff --git a/connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsEvent.kt b/connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsEvent.kt index 5e820aa7511..18c1b8e9e33 100644 --- a/connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsEvent.kt +++ b/connect/src/main/java/com/stripe/android/connect/analytics/ConnectAnalyticsEvent.kt @@ -31,7 +31,7 @@ internal sealed class ConnectAnalyticsEvent( * Note: This should happen before component_loaded, so we won't yet have a page_view_id. */ data class WebPageLoaded( - val timeToLoad: Double + val timeToLoad: Long ) : ConnectAnalyticsEvent( "component.web.page_loaded", mapOf("time_to_load" to timeToLoad.toString()) @@ -43,8 +43,8 @@ internal sealed class ConnectAnalyticsEvent( */ data class WebComponentLoaded( val pageViewId: String, - val timeToLoad: Double, - val perceivedTimeToLoad: Double + val timeToLoad: Long, + val perceivedTimeToLoad: Long ) : ConnectAnalyticsEvent( "component.web.component_loaded", mapOf( @@ -183,7 +183,7 @@ internal sealed class ConnectAnalyticsEvent( /** * The web page navigated somewhere other than the component wrapper URL - * (e.g. https://connect-js.stripe.com/v1.0/ios-webview.html) + * (e.g. https://connect-js.stripe.com/v1.0/android_webview.html) */ data class WebErrorUnexpectedNavigation( val url: String @@ -196,17 +196,13 @@ internal sealed class ConnectAnalyticsEvent( * Catch-all event for unexpected client-side errors. */ data class ClientError( - val domain: String, - val code: Int, - val file: String, - val line: Int + val error: String, + val errorMessage: String? = null, ) : ConnectAnalyticsEvent( "client_error", mapOf( - "domain" to domain, - "code" to code.toString(), - "file" to file, - "line" to line.toString() + "error" to error, + "errorMessage" to errorMessage, ) ) } diff --git a/connect/src/main/java/com/stripe/android/connect/util/AndroidClock.kt b/connect/src/main/java/com/stripe/android/connect/util/AndroidClock.kt new file mode 100644 index 00000000000..d98dbf618a7 --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/util/AndroidClock.kt @@ -0,0 +1,23 @@ +package com.stripe.android.connect.util + +/** + * [Clock] interface to be used to provide compatible `Clock` functionality, + * and one day be replaced by `java.time.Clock` when all consumers support > SDK 26. + * + * Also useful for mocking in tests. + */ +internal interface Clock { + + /** + * Return the current system time in milliseconds + */ + fun millis(): Long +} + +/** + * A [Clock] that depends on Android APIs. To be replaced by java.time.Clock when all consumers + * support > SDK 26. + */ +internal class AndroidClock : Clock { + override fun millis(): Long = System.currentTimeMillis() +} diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt index 9263592d6ff..59e3345c3e2 100644 --- a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt @@ -33,6 +33,7 @@ import com.stripe.android.connect.StripeEmbeddedComponentListener import com.stripe.android.connect.appearance.Appearance import com.stripe.android.connect.databinding.StripeConnectWebviewBinding import com.stripe.android.connect.toJsonObject +import com.stripe.android.connect.util.AndroidClock import com.stripe.android.connect.webview.serialization.AccountSessionClaimedMessage import com.stripe.android.connect.webview.serialization.ConnectInstanceJs import com.stripe.android.connect.webview.serialization.ConnectJson @@ -202,6 +203,7 @@ internal class StripeConnectWebViewContainerImpl( this.controller = StripeConnectWebViewContainerController( view = this, analyticsService = analyticsService, + clock = AndroidClock(), embeddedComponentManager = embeddedComponentManager, embeddedComponent = embeddedComponent, listener = listener, @@ -266,7 +268,11 @@ internal class StripeConnectWebViewContainerImpl( @VisibleForTesting internal inner class StripeConnectWebViewClient : WebViewClient() { override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { - controller?.onPageStarted() + controller?.onPageStarted(url) + } + + override fun onPageFinished(view: WebView?, url: String?) { + controller?.onPageFinished() } override fun onReceivedHttpError( @@ -371,7 +377,10 @@ internal class StripeConnectWebViewContainerImpl( @JavascriptInterface fun onSetterFunctionCalled(message: String) { - val parsed = ConnectJson.decodeFromString(message) + val parsed = tryDeserializeWebMessage( + webFunctionName = "onSetterFunctionCalled", + message = message, + ) ?: return logger.debug("Setter function called: $parsed") controller?.onReceivedSetterFunctionCalled(parsed) @@ -379,21 +388,30 @@ internal class StripeConnectWebViewContainerImpl( @JavascriptInterface fun openSecureWebView(message: String) { - val secureWebViewData = ConnectJson.decodeFromString(message) + val secureWebViewData = tryDeserializeWebMessage( + webFunctionName = "openSecureWebView", + message = message, + ) logger.debug("Open secure web view with data: $secureWebViewData") } @JavascriptInterface fun pageDidLoad(message: String) { - val pageLoadMessage = ConnectJson.decodeFromString(message) + val pageLoadMessage = tryDeserializeWebMessage( + webFunctionName = "pageDidLoad", + message = message, + ) ?: return logger.debug("Page did load: $pageLoadMessage") - controller?.onReceivedPageDidLoad() + controller?.onReceivedPageDidLoad(pageLoadMessage.pageViewId) } @JavascriptInterface fun accountSessionClaimed(message: String) { - val accountSessionClaimedMessage = ConnectJson.decodeFromString(message) + val accountSessionClaimedMessage = tryDeserializeWebMessage( + webFunctionName = "accountSessionClaimed", + message = message, + ) ?: return logger.debug("Account session claimed: $accountSessionClaimedMessage") controller?.onMerchantIdChanged(accountSessionClaimedMessage.merchantId) @@ -407,6 +425,22 @@ internal class StripeConnectWebViewContainerImpl( } } + private inline fun tryDeserializeWebMessage( + webFunctionName: String, + message: String, + ): T? { + return try { + ConnectJson.decodeFromString(message) + } catch (e: IllegalArgumentException) { + controller?.onErrorDeserializingWebMessage( + webMessage = message, + error = "Unable to deserialize message from $webFunctionName", + errorMessage = e.message, + ) + null + } + } + private fun WebView.evaluateSdkJs(function: String, payload: JsonObject) { val command = "$ANDROID_JS_INTERFACE.$function($payload)" post { diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt index f57da1de02a..bafaa6d1bd2 100644 --- a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt @@ -18,10 +18,13 @@ import com.stripe.android.connect.PrivateBetaConnectSDK import com.stripe.android.connect.StripeEmbeddedComponent import com.stripe.android.connect.StripeEmbeddedComponentListener import com.stripe.android.connect.analytics.ComponentAnalyticsService +import com.stripe.android.connect.analytics.ConnectAnalyticsEvent +import com.stripe.android.connect.util.Clock import com.stripe.android.connect.webview.serialization.ConnectInstanceJs import com.stripe.android.connect.webview.serialization.SetOnLoadError import com.stripe.android.connect.webview.serialization.SetOnLoaderStart import com.stripe.android.connect.webview.serialization.SetterFunctionCalledMessage +import com.stripe.android.connect.webview.serialization.SetterFunctionCalledMessage.UnknownValue import com.stripe.android.core.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -36,6 +39,7 @@ import kotlinx.coroutines.withContext internal class StripeConnectWebViewContainerController( private val view: StripeConnectWebViewContainerInternal, private val analyticsService: ComponentAnalyticsService, + private val clock: Clock, private val embeddedComponentManager: EmbeddedComponentManager, private val embeddedComponent: StripeEmbeddedComponent, private val listener: Listener?, @@ -44,6 +48,10 @@ internal class StripeConnectWebViewContainerController updateState { copy(appearance = appearance) } - if (stateFlow.value.receivedPageDidLoad) { + if (stateFlow.value.pageViewId != null) { view.updateConnectInstance(appearance) } } @@ -177,9 +237,14 @@ internal class StripeConnectWebViewContainerController { + if (value is UnknownValue) { + analyticsService.track( + ConnectAnalyticsEvent.WebWarnUnrecognizedSetter( + setter = message.setter, + pageViewId = stateFlow.value.pageViewId + ) + ) + } with(listenerDelegate) { listener?.delegate(message) } diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerState.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerState.kt index 052897530aa..f73eda7542c 100644 --- a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerState.kt +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerState.kt @@ -9,9 +9,15 @@ import com.stripe.android.connect.util.getContrastingColor @OptIn(PrivateBetaConnectSDK::class) internal data class StripeConnectWebViewContainerState( /** - * True if we received the 'pageDidLoad' message. + * Non-null if we received the 'pageDidLoad' message, + * null otherwise. */ - val receivedPageDidLoad: Boolean = false, + val pageViewId: String? = null, + + /** + * The time the webview began loading, in milliseconds from midnight, January 1, 1970 UTC. + */ + val didBeginLoadingMillis: Long? = null, /** * True if we received the 'setOnLoaderStart' message. diff --git a/connect/src/test/java/com/stripe/android/connect/analytics/ComponentAnalyticsServiceTest.kt b/connect/src/test/java/com/stripe/android/connect/analytics/ComponentAnalyticsServiceTest.kt index cfb823fd5eb..6410a91d558 100644 --- a/connect/src/test/java/com/stripe/android/connect/analytics/ComponentAnalyticsServiceTest.kt +++ b/connect/src/test/java/com/stripe/android/connect/analytics/ComponentAnalyticsServiceTest.kt @@ -86,8 +86,8 @@ class ComponentAnalyticsServiceTest { componentAnalyticsService.track( ConnectAnalyticsEvent.WebComponentLoaded( pageViewId = "pageViewId123", - timeToLoad = 100.0, - perceivedTimeToLoad = 50.0, + timeToLoad = 100L, + perceivedTimeToLoad = 50L, ) ) val mapCaptor = argumentCaptor>() @@ -96,14 +96,14 @@ class ComponentAnalyticsServiceTest { val expectedMetadata = mapOf( "page_view_id" to "pageViewId123", - "time_to_load" to "100.0", - "perceived_time_to_load" to "50.0", + "time_to_load" to "100", + "perceived_time_to_load" to "50", ) assertContains(params, "event_metadata") assertEquals(expectedMetadata, params["event_metadata"]) assertEquals("pageViewId123", params["page_view_id"]) - assertEquals("100.0", params["time_to_load"]) - assertEquals("50.0", params["perceived_time_to_load"]) + assertEquals("100", params["time_to_load"]) + assertEquals("50", params["perceived_time_to_load"]) } @Test diff --git a/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt b/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt index d1adb49f1d6..db6fe5ef556 100644 --- a/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt +++ b/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt @@ -16,8 +16,10 @@ import com.stripe.android.connect.PayoutsListener import com.stripe.android.connect.PrivateBetaConnectSDK import com.stripe.android.connect.StripeEmbeddedComponent import com.stripe.android.connect.analytics.ComponentAnalyticsService +import com.stripe.android.connect.analytics.ConnectAnalyticsEvent import com.stripe.android.connect.appearance.Appearance import com.stripe.android.connect.appearance.Colors +import com.stripe.android.connect.util.Clock import com.stripe.android.connect.webview.serialization.SetOnLoadError import com.stripe.android.connect.webview.serialization.SetOnLoadError.LoadError import com.stripe.android.connect.webview.serialization.SetOnLoaderStart @@ -54,6 +56,7 @@ class StripeConnectWebViewContainerControllerTest { private val mockPermissionRequest: PermissionRequest = mock() private val view: StripeConnectWebViewContainerInternal = mock() private val analyticsService: ComponentAnalyticsService = mock() + private val androidClock: Clock = mock() private val embeddedComponentManager: EmbeddedComponentManager = mock() private val embeddedComponent: StripeEmbeddedComponent = StripeEmbeddedComponent.PAYOUTS @@ -74,10 +77,13 @@ class StripeConnectWebViewContainerControllerTest { Dispatchers.setMain(Dispatchers.Unconfined) whenever(embeddedComponentManager.appearanceFlow) doReturn appearanceFlow + whenever(embeddedComponentManager.getStripeURL(any())) doReturn "https://example.com" + whenever(androidClock.millis()) doReturn -1L controller = StripeConnectWebViewContainerController( view = view, analyticsService = analyticsService, + clock = androidClock, embeddedComponentManager = embeddedComponentManager, embeddedComponent = embeddedComponent, listener = listener, @@ -104,14 +110,21 @@ class StripeConnectWebViewContainerControllerTest { } @Test - fun `shouldOverrideUrlLoading allows request and returns false for allowlisted hosts`() { + fun `shouldOverrideUrlLoading allows request and returns false for allowlisted hosts, logs unexpected pop-up`() { val uri = Uri.parse("https://connect-js.stripe.com/allowlisted") val mockRequest = mock { on { url } doReturn uri } + controller.onReceivedPageDidLoad("page_view_id") val result = controller.shouldOverrideUrlLoading(mockContext, mockRequest) assertFalse(result) + verify(analyticsService).track( + ConnectAnalyticsEvent.ClientError( + error = "Unexpected pop-up", + errorMessage = "Received pop-up for allow-listed host: https://connect-js.stripe.com/allowlisted", + ) + ) } @Test @@ -165,7 +178,7 @@ class StripeConnectWebViewContainerControllerTest { @Test fun `should handle SetOnLoaderStart`() = runTest { val message = SetterFunctionCalledMessage(SetOnLoaderStart("")) - controller.onPageStarted() + controller.onPageStarted("https://example.com") controller.onReceivedSetterFunctionCalled(message) val state = controller.stateFlow.value @@ -227,11 +240,11 @@ class StripeConnectWebViewContainerControllerTest { appearanceFlow.emit(appearances[0]) controller.onViewAttached() - controller.onPageStarted() + controller.onPageStarted("https://example.com") verify(view, never()).updateConnectInstance(any()) // Should update appearance when pageDidLoad is received. - controller.onReceivedPageDidLoad() + controller.onReceivedPageDidLoad("page_view_id") // Should update again when appearance changes. appearanceFlow.emit(appearances[1]) @@ -259,12 +272,18 @@ class StripeConnectWebViewContainerControllerTest { } @Test - fun `onPermissionRequest denies permission when no supported permissions are requested`() = runTest { + fun `onPermissionRequest denies when no supported permissions requested, logs unexpected permissions`() = runTest { whenever(mockPermissionRequest.resources) doReturn arrayOf("unsupported_permission") controller.onPermissionRequest(mockContext, mockPermissionRequest) verify(mockPermissionRequest).deny() + verify(analyticsService).track( + ConnectAnalyticsEvent.ClientError( + error = "Unexpected permissions request", + errorMessage = "Unexpected permissions 'unsupported_permission' requested", + ) + ) } @Test @@ -298,4 +317,110 @@ class StripeConnectWebViewContainerControllerTest { controller.onMerchantIdChanged("merchant_id") verify(analyticsService).merchantId = "merchant_id" } + + @Test + fun `emit component created analytic on init`() { + verify(analyticsService).track(ConnectAnalyticsEvent.ComponentCreated) + } + + @Test + fun `emit component viewed analytic on view attach`() { + // page view id is null since we haven't received pageDidLoad yet + controller.onViewAttached() + verify(analyticsService).track(ConnectAnalyticsEvent.ComponentViewed(null)) + + // once we receive pageDidLoad, we should emit the page view id for subsequent analytics + controller.onReceivedPageDidLoad("id123") + controller.onViewAttached() + verify(analyticsService).track(ConnectAnalyticsEvent.ComponentViewed("id123")) + } + + @Test + fun `emit unexpected navigation analytic if non-stripe url page started`() { + whenever( + embeddedComponentManager.getStripeURL(embeddedComponent) + ) doReturn "https://stripe.com/test?foo=bar#mytest" + + controller.onPageStarted("https://example.com/test?foo=bar#mytest") + + // when emitting the analytic, we should strip the query params + verify(analyticsService).track( + ConnectAnalyticsEvent.WebErrorUnexpectedNavigation("https://example.com/test") + ) + } + + @Test + fun `dont emit unexpected navigation analytic if expected stripe url is used on page started`() { + whenever( + embeddedComponentManager.getStripeURL(any()) + ) doReturn "https://stripe.com/test?foo=bar#mytest" + + controller.onPageStarted("https://stripe.com/test") + + // when emitting the analytic, we should strip the query params + verify(analyticsService, never()).track( + ConnectAnalyticsEvent.WebErrorUnexpectedNavigation("https://stripe.com/test") + ) + } + + @Test + fun `emit web page loaded analytic on page finished`() { + whenever(androidClock.millis()) doReturn 100L + controller.onViewAttached() // register that the page was attached to capture the start of loading + + whenever(androidClock.millis()) doReturn 200L // difference of 100ms to start of load + controller.onPageFinished() + verify(analyticsService).track(ConnectAnalyticsEvent.WebPageLoaded(100L)) + } + + @Test + fun `emit web component loaded analytic when received pageDidLoad`() { + whenever(androidClock.millis()) doReturn 100L + controller.onViewAttached() // register that the page was attached to capture the start of loading + + whenever(androidClock.millis()) doReturn 200L // difference of 100ms to start of load + controller.onReceivedPageDidLoad("pageView123") + verify(analyticsService).track( + ConnectAnalyticsEvent.WebComponentLoaded( + pageViewId = "pageView123", + timeToLoad = 100L, + perceivedTimeToLoad = 100L, + ) + ) + } + + @Test + fun `emit web page error when main page receives an error`() { + controller.onReceivedError( + requestUrl = "https://stripe.com", + httpStatusCode = 404, + errorMessage = "Not Found", + isMainPageLoad = true + ) + verify(analyticsService).track( + ConnectAnalyticsEvent.WebErrorPageLoad( + status = 404, + error = "Not Found", + url = "https://stripe.com", + ) + ) + } + + @Test + fun `emit deserialization error on error to deserialize web message`() { + controller.onReceivedPageDidLoad("page_view_id") + controller.onErrorDeserializingWebMessage( + webMessage = "{ invalid: 4 ", + error = "Unable to deserialize", + errorMessage = "Error parsing JSON" + ) + verify(analyticsService).track( + ConnectAnalyticsEvent.WebErrorDeserializeMessage( + message = "{ invalid: 4 ", + error = "Unable to deserialize", + errorDescription = "Error parsing JSON", + pageViewId = "page_view_id", + ) + ) + } }