From cfbfdd130a59079bfbd31345b741adf6684b4f9b Mon Sep 17 00:00:00 2001 From: sozinov Date: Fri, 24 Jul 2026 18:25:05 +0300 Subject: [PATCH 1/2] MOBILE-311: Heal poisoned in-app WebView cache --- kmp-common-sdk | 2 +- .../InAppWebViewPrewarmManager.kt | 1 + .../view/WebViewInappViewHolder.kt | 48 +++++++- .../view/WebViewNoCacheRetryPolicy.kt | 60 ++++++++++ .../InAppWebViewPrewarmManagerImplTest.kt | 30 ++++- .../view/WebViewNoCacheRetryPolicyTest.kt | 107 ++++++++++++++++++ 6 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewNoCacheRetryPolicy.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewNoCacheRetryPolicyTest.kt diff --git a/kmp-common-sdk b/kmp-common-sdk index 6cfaa316..d8d4e32f 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 6cfaa316f450a08fa936bfac681af2cbe75bff52 +Subproject commit d8d4e32f1d9096b666955edc5f3b9b47153239aa diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt index e6ad0036..f42c93b4 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt @@ -247,6 +247,7 @@ internal class InAppWebViewPrewarmManagerImpl( // the engine, per this function's own doc comment. if (hasAborted.get() || latestConfigHasNoLayers.get()) return if (!hasStartedResourcePrewarm.compareAndSet(false, true)) return + engine.onNoCacheRetryStarted = { scheduleSettleRelease() } val userAgentSuffix = configuration.getShortUserAgent() mindboxLogI("[WebView] Prewarm: preconnect to ${plan.preconnectOrigins.joinToString(",")} under ${plan.baseUrl}") diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 7ef1db80..0c6c1c2c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -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) + } + 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 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. diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewNoCacheRetryPolicy.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewNoCacheRetryPolicy.kt new file mode 100644 index 00000000..e5ddaa43 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewNoCacheRetryPolicy.kt @@ -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 for " 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 + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt index c7faa4e3..45bffdf0 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt @@ -51,7 +51,7 @@ internal class InAppWebViewPrewarmManagerImplTest { previousInstallationId = "", previousDeviceUUID = "", endpointId = "Test.Endpoint", - domain = "api.mindbox.ru", + domain = "api.example.com", packageName = "test.package", versionName = "1.0", versionCode = "1", @@ -145,7 +145,7 @@ internal class InAppWebViewPrewarmManagerImplTest { engine.loadPreconnectPage( html = match { html -> html.contains("https://mobile-static.mindbox.ru") && - html.contains("https://api.mindbox.ru") && + html.contains("https://api.example.com") && html.contains("https://learned-cdn.mindbox.ru") }, baseUrl = "https://inapp.local/popup", @@ -516,6 +516,32 @@ internal class InAppWebViewPrewarmManagerImplTest { verify(exactly = 1) { mobileConfigSerializationManager.deserializeToConfigDtoBlank(any()) } } + @Test + fun `no-cache retry restarts the settle budget so the healing load gets its own 30s`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // The page never goes idle (entry count keeps growing) -> only the hard cap releases. + var count = 0 + every { engine.evaluateJavaScript(any(), any()) } answers { + count++ + secondArg<(String?) -> Unit>().invoke("\"$count:100\"") + } + + service.prewarmResources(configWith(webViewInApp)) + val retryCallback = io.mockk.slot<() -> Unit>() + verify { engine.onNoCacheRetryStarted = capture(retryCallback) } + + // The engine reports a no-cache reload just before the original budget would expire. + advanceTimeBy(29_000) + verify(exactly = 0) { engine.release() } + retryCallback.captured.invoke() + + // Old budget (would fire at 30s) is cancelled; the fresh one runs a full 30s more. + advanceTimeBy(29_000) + verify(exactly = 0) { engine.release() } + advanceTimeBy(2_000) + verify(exactly = 1) { engine.release() } + } + @Test fun `prewarmOnInit primes the cache toggle even when the prewarm toggle is off`() { every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = false, cacheToggle = false) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewNoCacheRetryPolicyTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewNoCacheRetryPolicyTest.kt new file mode 100644 index 00000000..eff50690 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewNoCacheRetryPolicyTest.kt @@ -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)) + 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) + } +} From d491121817f175d3a67156c5e45f3d5b63c131d6 Mon Sep 17 00:00:00 2001 From: sozinov Date: Sat, 25 Jul 2026 13:52:50 +0300 Subject: [PATCH 2/2] MOBILE-311: follow review --- .../inapp/presentation/view/WebViewInappViewHolder.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 0c6c1c2c..027eae1e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -480,7 +480,7 @@ internal class WebViewInAppViewHolder( if (isRecoverableScriptHttpError(url, statusCode)) { mindboxLogW("[WebView] HTTP error $statusCode for $url (mainFrame=$isForMainFrame)") } else { - mindboxLogD("[WebView] HTTP error $statusCode for $url (mainFrame=$isForMainFrame)") + mindboxLogI("[WebView] HTTP error $statusCode for $url (mainFrame=$isForMainFrame)") } if (noCacheRetryPolicy.onHttpError(url, statusCode, hasInitialized)) { retryContentPageWithoutCache() @@ -494,7 +494,10 @@ internal class WebViewInAppViewHolder( val controller = webViewController ?: return val content = lastLoadedContent ?: return mindboxLogI("[WebView] Retrying In-App content load with cache bypassed (${noCacheRetryPolicy.lastHttpErrorDetail})") - stopTimer() + // stop timer when retry + closeInappTimer?.cancel() + closeInappTimer = null + Stopwatch.stop(TIMER) readyChecker?.cancel() controller.setCacheBypass(true) onContentPageLoaded(content)