Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mindboxLogD не используем. Надо бы боту сказать и пометить анотацией метод

}
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
Expand Down Expand Up @@ -777,12 +811,13 @@ internal class WebViewInAppViewHolder(
webViewController?.let { controller ->
hasInitialized = false
pendingReadyCheckFailure = null
lastLoadedContent = content

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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) {
Expand All @@ -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
)
Expand Down Expand Up @@ -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.
Expand Down
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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)
}
}