Skip to content

Commit 87e735c

Browse files
authored
andr(example): Prototype a Webview instrumenter (#803)
1 parent 97f7344 commit 87e735c

4 files changed

Lines changed: 164 additions & 62 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// capture-sdk - bitdrift's client SDK
2+
// Copyright Bitdrift, Inc. All rights reserved.
3+
//
4+
// Use of this source code is governed by a source available license that can be found in the
5+
// LICENSE file or at:
6+
// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt
7+
8+
package io.bitdrift.gradletestapp.diagnostics.webview
9+
10+
import android.graphics.Bitmap
11+
import android.webkit.WebResourceError
12+
import android.webkit.WebResourceRequest
13+
import android.webkit.WebResourceResponse
14+
import android.webkit.WebView
15+
import android.webkit.WebViewClient
16+
import androidx.webkit.WebViewCompat
17+
import androidx.webkit.WebViewFeature
18+
import io.bitdrift.capture.Capture
19+
import io.bitdrift.capture.ILogger
20+
import io.bitdrift.capture.LogLevel
21+
import io.bitdrift.capture.events.span.Span
22+
import io.bitdrift.capture.events.span.SpanResult
23+
24+
class WebViewCapture(
25+
private val original: WebViewClient,
26+
private val logger: ILogger? = Capture.logger(),
27+
) : WebViewClient() {
28+
29+
// all WebViewClient callbacks are guaranteed to happen on the main thread, so no need for synchronization
30+
private var pageLoad: Span? = null
31+
private var ongoingRequest: PageLoadRequest? = null
32+
33+
// attempts to get the latest logger if one wasn't found at construction time
34+
private fun getLogger(): ILogger? = logger ?: Capture.logger()
35+
36+
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
37+
if (ongoingRequest == null) {
38+
// Only for when onGoingRequest wasn't already set by an early error callback
39+
ongoingRequest = PageLoadRequest(url.orEmpty())
40+
}
41+
pageLoad = getLogger()?.startSpan("webview.pageLoad", LogLevel.DEBUG, ongoingRequest?.fields)
42+
43+
original.onPageStarted(view, url, favicon)
44+
}
45+
46+
// This callback always happens at the end, even after error callbacks
47+
override fun onPageFinished(view: WebView?, url: String?) {
48+
ongoingRequest?.let {
49+
if (it.isError) {
50+
pageLoad?.end(SpanResult.FAILURE, it.fields)
51+
} else {
52+
pageLoad?.end(SpanResult.SUCCESS)
53+
}
54+
}
55+
pageLoad = null
56+
ongoingRequest = null
57+
58+
original.onPageFinished(view, url)
59+
}
60+
61+
override fun onReceivedError(
62+
view: WebView?,
63+
request: WebResourceRequest?,
64+
error: WebResourceError?
65+
) {
66+
// Only handle errors for the main page load, not for sub-resources like images.
67+
if (request?.isForMainFrame == true) {
68+
ongoingRequest = PageLoadRequest(
69+
url = request.url.toString(),
70+
isError = true,
71+
errorCode = error?.errorCode.toString(),
72+
errorCodeName = error?.errorCode?.toErrorCodeName(),
73+
errorDescription = error?.description?.toString(),
74+
)
75+
}
76+
original.onReceivedError(view, request, error)
77+
}
78+
79+
// This callback can happen very early; even before onPageStarted
80+
// see: https://issuetracker.google.com/issues/210920403
81+
override fun onReceivedHttpError(
82+
view: WebView?,
83+
request: WebResourceRequest?,
84+
errorResponse: WebResourceResponse?
85+
) {
86+
// Only handle errors for the main page load, not for sub-resources like images.
87+
if (request?.isForMainFrame == true) {
88+
ongoingRequest = PageLoadRequest(
89+
url = request.url.toString(),
90+
isError = true,
91+
errorCode = errorResponse?.statusCode.toString(),
92+
errorDescription = errorResponse?.reasonPhrase,
93+
)
94+
}
95+
super.onReceivedHttpError(view, request, errorResponse)
96+
}
97+
98+
private fun Int.toErrorCodeName(): String =
99+
when (this) {
100+
ERROR_UNKNOWN -> "UNKNOWN"
101+
ERROR_HOST_LOOKUP -> "HOST_LOOKUP"
102+
ERROR_UNSUPPORTED_AUTH_SCHEME -> "UNSUPPORTED_AUTH_SCHEME"
103+
ERROR_AUTHENTICATION -> "AUTHENTICATION"
104+
ERROR_PROXY_AUTHENTICATION -> "PROXY_AUTHENTICATION"
105+
ERROR_CONNECT -> "CONNECT"
106+
ERROR_IO -> "IO"
107+
ERROR_TIMEOUT -> "TIMEOUT"
108+
ERROR_REDIRECT_LOOP -> "REDIRECT_LOOP"
109+
ERROR_UNSUPPORTED_SCHEME -> "UNSUPPORTED_SCHEME"
110+
ERROR_FAILED_SSL_HANDSHAKE -> "FAILED_SSL_HANDSHAKE"
111+
ERROR_BAD_URL -> "BAD_URL"
112+
ERROR_FILE -> "FILE"
113+
ERROR_FILE_NOT_FOUND -> "FILE_NOT_FOUND"
114+
ERROR_TOO_MANY_REQUESTS -> "TOO_MANY_REQUESTS"
115+
ERROR_UNSAFE_RESOURCE -> "UNSAFE_RESOURCE"
116+
else -> "UNKNOWN"
117+
}
118+
119+
private data class PageLoadRequest(
120+
val url: String,
121+
val isError: Boolean = false,
122+
val errorCode: String? = null,
123+
val errorCodeName: String? = null,
124+
val errorDescription: String? = null,
125+
) {
126+
val fields: Map<String, String> by lazy {
127+
buildMap {
128+
put("_url", url)
129+
if (isError) {
130+
errorCode?.let { put("_errorCode", it) }
131+
errorCodeName?.let { put("_errorCodeName", it) }
132+
errorDescription?.let { put("_description", it) }
133+
}
134+
}
135+
}
136+
}
137+
138+
companion object {
139+
fun instrument(webview: WebView) {
140+
if (WebViewFeature.isFeatureSupported(WebViewFeature.GET_WEB_VIEW_CLIENT)) {
141+
val original = WebViewCompat.getWebViewClient(webview)
142+
webview.webViewClient = WebViewCapture(original)
143+
}
144+
}
145+
}
146+
}

platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/fragments/FirstFragment.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,11 @@ class FirstFragment : Fragment() {
5959
private val composeView get() = _composeView!!
6060
private lateinit var clipboardManager: ClipboardManager
6161

62-
private var firstFragmentToCopySessionSpan: Span? = null
63-
6462
override fun onCreateView(
6563
inflater: LayoutInflater,
6664
container: ViewGroup?,
6765
savedInstanceState: Bundle?,
6866
): View {
69-
firstFragmentToCopySessionSpan =
70-
Logger.startSpan("CreateFragmentToCopySessionClick", LogLevel.INFO)
71-
7267
_composeView = ComposeView(requireContext())
7368

7469
clipboardManager =
@@ -137,8 +132,6 @@ class FirstFragment : Fragment() {
137132
super.onViewCreated(view, savedInstanceState)
138133

139134
Logger.logScreenView("first_fragment")
140-
141-
firstFragmentToCopySessionSpan?.end(SpanResult.SUCCESS)
142135
}
143136

144137
override fun onDestroyView() {

platform/jvm/gradle-test-app/src/main/java/io/bitdrift/gradletestapp/ui/fragments/WebViewFragment.kt

Lines changed: 13 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,14 @@
77

88
package io.bitdrift.gradletestapp.ui.fragments
99

10-
import android.graphics.Bitmap
1110
import android.os.Bundle
1211
import android.view.LayoutInflater
1312
import android.view.View
1413
import android.view.ViewGroup
15-
import android.webkit.WebResourceError
16-
import android.webkit.WebResourceRequest
1714
import android.webkit.WebView
18-
import android.webkit.WebViewClient
1915
import androidx.fragment.app.Fragment
20-
import androidx.webkit.WebViewCompat
21-
import androidx.webkit.WebViewFeature
22-
import io.bitdrift.capture.Capture
23-
import io.bitdrift.capture.LogLevel
24-
import io.bitdrift.capture.events.span.Span
25-
import io.bitdrift.capture.events.span.SpanResult
2616
import io.bitdrift.gradletestapp.R
27-
import kotlin.concurrent.Volatile
17+
import io.bitdrift.gradletestapp.diagnostics.webview.WebViewCapture
2818

2919
/**
3020
* A basic WebView that can be used to test multi process.
@@ -38,49 +28,20 @@ class WebViewFragment : Fragment() {
3828
): View {
3929
val view = inflater.inflate(R.layout.fragment_web_view, container, false)
4030
val webView = view.findViewById<WebView>(R.id.webView)
41-
if (WebViewFeature.isFeatureSupported(WebViewFeature.GET_WEB_VIEW_CLIENT)) {
42-
val original = WebViewCompat.getWebViewClient(webView)
43-
webView.webViewClient = WebViewClientWrapper(original)
44-
}
45-
webView.loadUrl("https://bitdrift.io/")
46-
return view
47-
}
4831

49-
class WebViewClientWrapper(private val original : WebViewClient) : WebViewClient() {
50-
@Volatile
51-
var onPageSpan: Span? = null
52-
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
53-
val fields = url?.let {
54-
mapOf("_url" to it)
55-
}
56-
onPageSpan = Capture.Logger.startSpan("WebViewFragment.onPage", LogLevel.INFO, fields)
57-
original.onPageStarted(view, url, favicon)
58-
}
59-
override fun onPageFinished(view: WebView?, url: String?) {
60-
original.onPageFinished(view, url)
61-
onPageSpan?.end(SpanResult.SUCCESS)
62-
onPageSpan = null
63-
}
32+
// Instrument the WebView with bitdrift capture
33+
WebViewCapture.instrument(webView)
6434

65-
override fun onReceivedError(
66-
view: WebView?,
67-
request: WebResourceRequest?,
68-
error: WebResourceError?
69-
) {
70-
// Only handle errors for the main page load, not for sub-resources like images.
71-
if (request?.isForMainFrame == true) {
72-
val fields = mapOf(
73-
"_errorCode" to error?.errorCode.toString(),
74-
"_description" to error?.description?.toString().orEmpty(),
75-
"_url" to request.url.toString(),
76-
)
77-
Capture.Logger.logError(fields = fields) { "WebViewFragment.onReceivedError" }
35+
webView.loadUrl(urls.random())
36+
return view
37+
}
7838

79-
// End the span with a failure result
80-
onPageSpan?.end(SpanResult.FAILURE)
81-
onPageSpan = null
82-
}
83-
original.onReceivedError(view, request, error)
84-
}
39+
companion object {
40+
private val urls = listOf(
41+
"https://bitdrift.io/",
42+
"https://bitdrift.io/hello", // 404
43+
"https://bitdrift.ai/", // timeout
44+
"https://www.wikipedia.org/",
45+
)
8546
}
8647
}

platform/jvm/replay/src/main/kotlin/io/bitdrift/capture/replay/internal/ViewMapperConfiguration.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ internal class ViewMapperConfiguration(
3535
map.toMap()
3636
}
3737

38-
// TODO(murki): Clean-up the list below for Compose and provide a Modifier to let users mark things a types instead.
3938
private val defaultMapper: Map<ReplayType, List<String>> =
4039
mapOf(
4140
ReplayType.View to
@@ -88,7 +87,7 @@ internal class ViewMapperConfiguration(
8887
ReplayType.SwitchOff to
8988
listOf(
9089
// Compose (Foundation)
91-
"Checkbox", // TODO(murki): Figure how to handle on/off state for Compose
90+
"Checkbox",
9291
),
9392
ReplayType.Keyboard to emptyList(),
9493
ReplayType.Map to
@@ -102,6 +101,9 @@ internal class ViewMapperConfiguration(
102101
"TextField",
103102
),
104103
ReplayType.TransparentView to emptyList(),
105-
ReplayType.WebView to emptyList(),
104+
ReplayType.WebView to
105+
listOf(
106+
"WebView",
107+
),
106108
)
107109
}

0 commit comments

Comments
 (0)