-
Notifications
You must be signed in to change notification settings - Fork 6
MOBILE-311: Heal poisoned in-app WebView cache #746
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sergeysozinov
wants to merge
1
commit into
develop
Choose a base branch
from
feature/MOBILE-311
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Submodule kmp-common-sdk
updated
5 files
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -85,7 +85,13 @@ internal class WebViewInAppViewHolder( | |
| private val mainHandler: Handler = Handler(Looper.getMainLooper()) | ||
|
|
||
| private var hasInitialized = false | ||
| private var hasShownFired = false | ||
| private var pendingReadyCheckFailure: String? = null | ||
| private var lastLoadedContent: WebViewHtmlContent? = null | ||
|
|
||
| private val noCacheRetryPolicy: WebViewNoCacheRetryPolicy = WebViewNoCacheRetryPolicy { | ||
| webViewCachePolicy.isCacheEnabled | ||
| } | ||
|
|
||
| private var motionService: MotionServiceProtocol? = null | ||
|
|
||
|
|
@@ -291,7 +297,14 @@ internal class WebViewInAppViewHolder( | |
| private fun handleInitAction(controller: WebViewController): String { | ||
| hasInitialized = true | ||
| stopTimer() | ||
| wrapper.inAppActionCallbacks.onInAppShown.onShown() | ||
| if (noCacheRetryPolicy.hasRetried) { | ||
| controller.setCacheBypass(false) | ||
| } | ||
| lastLoadedContent = null | ||
| if (!hasShownFired) { | ||
| hasShownFired = true | ||
| wrapper.inAppActionCallbacks.onInAppShown.onShown() | ||
| } | ||
| val mindboxView = currentMindboxView ?: run { | ||
| mindboxLogW("MindboxView is null when activating WebView In-App") | ||
| inAppController.close() | ||
|
|
@@ -462,10 +475,31 @@ internal class WebViewInAppViewHolder( | |
| inAppController.close() | ||
| } | ||
| } | ||
|
|
||
| override fun onHttpError(url: String?, statusCode: Int?, isForMainFrame: Boolean?) { | ||
| if (isRecoverableScriptHttpError(url, statusCode)) { | ||
| mindboxLogW("[WebView] HTTP error $statusCode for $url (mainFrame=$isForMainFrame)") | ||
| } else { | ||
| mindboxLogD("[WebView] HTTP error $statusCode for $url (mainFrame=$isForMainFrame)") | ||
| } | ||
| if (noCacheRetryPolicy.onHttpError(url, statusCode, hasInitialized)) { | ||
| retryContentPageWithoutCache() | ||
| } | ||
| } | ||
| }) | ||
| return controller | ||
| } | ||
|
|
||
| private fun retryContentPageWithoutCache() { | ||
| val controller = webViewController ?: return | ||
| val content = lastLoadedContent ?: return | ||
| mindboxLogI("[WebView] Retrying In-App content load with cache bypassed (${noCacheRetryPolicy.lastHttpErrorDetail})") | ||
| stopTimer() | ||
| readyChecker?.cancel() | ||
| controller.setCacheBypass(true) | ||
| onContentPageLoaded(content) | ||
|
Comment on lines
+496
to
+500
|
||
| } | ||
|
|
||
| private fun handleShouldOverrideUrlLoading(url: String?, isForMainFrame: Boolean?): Boolean { | ||
| if (isForMainFrame != true) { | ||
| return false | ||
|
|
@@ -777,12 +811,13 @@ internal class WebViewInAppViewHolder( | |
| webViewController?.let { controller -> | ||
| hasInitialized = false | ||
| pendingReadyCheckFailure = null | ||
| lastLoadedContent = content | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. дополнительный уровень кэша? |
||
| controller.loadContent(content) | ||
| startTimer { | ||
| // A ready-check give-up that already fired before init is the more specific | ||
| // reason this timed out — report it instead of the generic load-timeout so | ||
| // the "ready check never passed" category isn't lost. | ||
| val readyCheckFailure = pendingReadyCheckFailure | ||
| val httpErrorSuffix = noCacheRetryPolicy.lastHttpErrorDetail?.let { detail -> | ||
| " Last script HTTP error: $detail; no-cache retry attempted: ${noCacheRetryPolicy.hasRetried}." | ||
| }.orEmpty() | ||
| inAppFailureTracker.sendFailureWithContext( | ||
| inAppId = wrapper.inAppType.inAppId, | ||
| failureReason = if (readyCheckFailure != null) { | ||
|
|
@@ -791,9 +826,9 @@ internal class WebViewInAppViewHolder( | |
| FailureReason.WEBVIEW_LOAD_FAILED | ||
| }, | ||
| errorDescription = if (readyCheckFailure != null) { | ||
| "JS ready check never passed before init timeout: $readyCheckFailure" | ||
| "JS ready check never passed before init timeout: $readyCheckFailure$httpErrorSuffix" | ||
| } else { | ||
| "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." | ||
| "WebView initialization timed out after ${Stopwatch.stop(TIMER)}.$httpErrorSuffix" | ||
| }, | ||
| tags = wrapper.tags | ||
| ) | ||
|
|
@@ -861,6 +896,7 @@ internal class WebViewInAppViewHolder( | |
| stopTimer() | ||
| readyChecker?.cancel() | ||
| readyChecker = null | ||
| lastLoadedContent = null | ||
| cancelPendingResponses("WebView In-App is closed") | ||
| webViewController?.let { controller -> | ||
| // Remember which https hosts this show actually used — feeds the next launch's preconnect. | ||
|
|
||
60 changes: 60 additions & 0 deletions
60
...c/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewNoCacheRetryPolicy.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| package cloud.mindbox.mobile_sdk.inapp.presentation.view | ||
|
|
||
| import cloud.mindbox.mobile_sdk.inapp.webview.isRecoverableScriptHttpError | ||
|
|
||
| /** | ||
| * Decides whether an HTTP error on a page subresource warrants reloading the in-app's | ||
| * content page with the HTTP cache bypassed. | ||
| * | ||
| * Exists for one failure mode: the CDN serves error responses with cacheable headers | ||
| * (`Cache-Control: public, max-age=86400` observed live), so a transient 404 on a | ||
| * bootstrap script gets stored by Chromium and replayed from the cache for a day — | ||
| * the runtime never boots and every show dies on the init timeout. A single reload | ||
| * with `LOAD_NO_CACHE` both recovers this show and overwrites the poisoned entry. | ||
| * | ||
| * Scope guards, in order: | ||
| * - only statuses >= 400 (redirects and successes are not errors); | ||
| * - only script resources (a broken image or stats beacon can't stop the runtime | ||
| * from booting — reloading a page over one would be a regression); | ||
| * - only before the runtime's `init` (after it the page has proven it can boot; | ||
| * a late lazy-module error must not tear down a live, visible in-app); | ||
| * - one retry per page load — if the fresh network answer is the same error, the | ||
| * existing init-timeout path closes the in-app as before; | ||
| * - the cache feature gate (the latched `MobileSdkShouldCacheInAppWebView` decision the | ||
| * WebView was created with — no separate retry toggle by design: with the cache off | ||
| * nothing can be poisoned and a reload would repeat the exact failed request), checked | ||
| * LAST and only for the retry decision, so [lastHttpErrorDetail] still enriches | ||
| * timeout telemetry when the cache is off. | ||
| * | ||
| * Pure logic, JVM-testable (mirrors [WebViewReadyChecker]). Writes are main-confined | ||
| * (WebViewClient callbacks); the fields are @Volatile only because the init-timeout | ||
| * telemetry reads them from the Timer thread. | ||
| */ | ||
| internal class WebViewNoCacheRetryPolicy( | ||
| private val isCacheFeatureEnabled: () -> Boolean | ||
| ) { | ||
|
|
||
| /** "HTTP <status> for <url>" of the last script error — telemetry context for the init timeout. */ | ||
| @Volatile | ||
| var lastHttpErrorDetail: String? = null | ||
| private set | ||
|
|
||
| /** True once a retry has been granted; later errors only update [lastHttpErrorDetail]. */ | ||
| @Volatile | ||
| var hasRetried: Boolean = false | ||
| private set | ||
|
|
||
| /** | ||
| * Records the error and returns true when the caller should perform the one-shot | ||
| * no-cache reload of the content page. | ||
| */ | ||
| fun onHttpError(url: String?, statusCode: Int?, hasInitialized: Boolean): Boolean { | ||
| if (!isRecoverableScriptHttpError(url, statusCode)) return false | ||
| lastHttpErrorDetail = "HTTP $statusCode for $url" | ||
| if (hasInitialized) return false | ||
| if (hasRetried) return false | ||
| if (!isCacheFeatureEnabled()) return false | ||
| hasRetried = true | ||
| return true | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
107 changes: 107 additions & 0 deletions
107
...st/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewNoCacheRetryPolicyTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| package cloud.mindbox.mobile_sdk.inapp.presentation.view | ||
|
|
||
| import org.junit.Assert.assertEquals | ||
| import org.junit.Assert.assertFalse | ||
| import org.junit.Assert.assertNull | ||
| import org.junit.Assert.assertTrue | ||
| import org.junit.Test | ||
|
|
||
| internal class WebViewNoCacheRetryPolicyTest { | ||
|
|
||
| private val trackerUrl = "https://api.example.com/scripts/v1/tracker.js?v=1.0.31" | ||
|
|
||
| private fun policy(cacheEnabled: Boolean = true) = WebViewNoCacheRetryPolicy { cacheEnabled } | ||
|
|
||
| @Test | ||
| fun `grants a retry for a script 404 before init`() { | ||
| val policy = policy() | ||
|
|
||
| assertTrue(policy.onHttpError(trackerUrl, 404, hasInitialized = false)) | ||
| assertTrue(policy.hasRetried) | ||
| assertEquals("HTTP 404 for $trackerUrl", policy.lastHttpErrorDetail) | ||
| } | ||
|
|
||
| @Test | ||
| fun `grants a retry for a server error on a script`() { | ||
| assertTrue(policy().onHttpError("https://cdn.test/main.js", 503, hasInitialized = false)) | ||
| } | ||
|
|
||
| @Test | ||
| fun `grants only one retry per page load`() { | ||
| val policy = policy() | ||
|
|
||
| assertTrue(policy.onHttpError(trackerUrl, 404, hasInitialized = false)) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ошибки 500 так же будут работаь? |
||
| assertFalse(policy.onHttpError(trackerUrl, 404, hasInitialized = false)) | ||
| // The later error still refreshes the telemetry detail. | ||
| assertFalse(policy.onHttpError("https://cdn.test/other.js", 500, hasInitialized = false)) | ||
| assertEquals("HTTP 500 for https://cdn.test/other.js", policy.lastHttpErrorDetail) | ||
| } | ||
|
|
||
| @Test | ||
| fun `does not retry after the runtime initialized`() { | ||
| val policy = policy() | ||
|
|
||
| assertFalse(policy.onHttpError(trackerUrl, 404, hasInitialized = true)) | ||
| assertFalse(policy.hasRetried) | ||
| // A live in-app must not be reloaded, but the error is still worth remembering. | ||
| assertEquals("HTTP 404 for $trackerUrl", policy.lastHttpErrorDetail) | ||
| } | ||
|
|
||
| @Test | ||
| fun `does not retry non-script resources`() { | ||
| val policy = policy() | ||
|
|
||
| assertFalse(policy.onHttpError("https://cdn.test/banner.png", 404, hasInitialized = false)) | ||
| assertFalse( | ||
| policy.onHttpError( | ||
| "https://personalization-speedtest.g.mindbox.ru/client-stats?x=1", | ||
| 404, | ||
| hasInitialized = false | ||
| ) | ||
| ) | ||
| assertNull(policy.lastHttpErrorDetail) | ||
| } | ||
|
|
||
| @Test | ||
| fun `does not retry on statuses below 400 or missing status`() { | ||
| val policy = policy() | ||
|
|
||
| assertFalse(policy.onHttpError(trackerUrl, 302, hasInitialized = false)) | ||
| assertFalse(policy.onHttpError(trackerUrl, 200, hasInitialized = false)) | ||
| assertFalse(policy.onHttpError(trackerUrl, null, hasInitialized = false)) | ||
| assertNull(policy.lastHttpErrorDetail) | ||
| assertFalse(policy.hasRetried) | ||
| } | ||
|
|
||
| @Test | ||
| fun `retries on the boundary status 400`() { | ||
| assertTrue(policy().onHttpError(trackerUrl, 400, hasInitialized = false)) | ||
| } | ||
|
|
||
| @Test | ||
| fun `cache feature off blocks the retry but keeps the telemetry detail`() { | ||
| val policy = policy(cacheEnabled = false) | ||
|
|
||
| assertFalse(policy.onHttpError(trackerUrl, 404, hasInitialized = false)) | ||
| assertFalse(policy.hasRetried) | ||
| assertEquals("HTTP 404 for $trackerUrl", policy.lastHttpErrorDetail) | ||
| } | ||
|
|
||
| @Test | ||
| fun `cache gate is consulted only when a retry would actually fire`() { | ||
| var consulted = 0 | ||
| val policy = WebViewNoCacheRetryPolicy { | ||
| consulted++ | ||
| true | ||
| } | ||
|
|
||
| policy.onHttpError("https://cdn.test/banner.png", 404, hasInitialized = false) | ||
| assertEquals(0, consulted) | ||
|
|
||
| policy.onHttpError(trackerUrl, 404, hasInitialized = true) | ||
| assertEquals(0, consulted) | ||
|
|
||
| policy.onHttpError(trackerUrl, 404, hasInitialized = false) | ||
| assertEquals(1, consulted) | ||
| } | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mindboxLogD не используем. Надо бы боту сказать и пометить анотацией метод