Coverage Summary for Class: InAppWebViewPrewarmManagerImpl (cloud.mindbox.mobile_sdk.inapp.presentation)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| InAppWebViewPrewarmManagerImpl |
86.7%
(13/15)
|
75%
(39/52)
|
91.4%
(85/93)
|
89.4%
(531/594)
|
| InAppWebViewPrewarmManagerImpl$captureObservedHosts$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/3)
|
0%
(0/24)
|
| InAppWebViewPrewarmManagerImpl$captureObservedHosts$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/19)
|
| InAppWebViewPrewarmManagerImpl$captureObservedHosts$1$1$1 |
0%
(0/1)
|
0%
(0/4)
|
0%
(0/3)
|
0%
(0/34)
|
| InAppWebViewPrewarmManagerImpl$Companion |
|
| InAppWebViewPrewarmManagerImpl$currentConfiguration$1 |
|
| InAppWebViewPrewarmManagerImpl$parseObservedHosts$1 |
0%
(0/1)
|
0%
(0/12)
|
0%
(0/5)
|
0%
(0/68)
|
| InAppWebViewPrewarmManagerImpl$prewarmOnInit$1 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(17/17)
|
| InAppWebViewPrewarmManagerImpl$prewarmOnInit$1$1 |
100%
(1/1)
|
100%
(4/4)
|
100%
(8/8)
|
100%
(51/51)
|
| InAppWebViewPrewarmManagerImpl$prewarmResources$1 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(18/18)
|
| InAppWebViewPrewarmManagerImpl$prewarmResources$1$1 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(13/13)
|
| InAppWebViewPrewarmManagerImpl$probeResourceState$2 |
100%
(1/1)
|
|
100%
(3/3)
|
100%
(15/15)
|
| InAppWebViewPrewarmManagerImpl$probeResourceState$2$1$1 |
100%
(1/1)
|
62.5%
(10/16)
|
100%
(7/7)
|
94%
(78/83)
|
| InAppWebViewPrewarmManagerImpl$probeResourceState$2$1$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
| InAppWebViewPrewarmManagerImpl$ResourceProbe |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(12/12)
|
| InAppWebViewPrewarmManagerImpl$scheduleSettleRelease$job$1 |
100%
(1/1)
|
81.2%
(13/16)
|
100%
(24/24)
|
98.6%
(138/140)
|
| InAppWebViewPrewarmManagerImpl$startResourcePrewarm$1 |
|
| InAppWebViewPrewarmManagerImpl$startResourcePrewarm$2 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(3/3)
|
| InAppWebViewPrewarmManagerImpl$webViewLayers$$inlined$filterIsInstance$1 |
0%
(0/1)
|
|
| InAppWebViewPrewarmManagerImpl$webViewLayers$1 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(6/6)
|
| InAppWebViewPrewarmManagerImpl$webViewLayers$2 |
100%
(1/1)
|
50%
(5/10)
|
100%
(4/4)
|
87.5%
(35/40)
|
| InAppWebViewPrewarmManagerImpl$webViewLayers$3 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(6/6)
|
| InAppWebViewPrewarmManagerImpl$webViewLayers$4 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(7/7)
|
| Total |
76.5%
(26/34)
|
61.2%
(71/116)
|
86.9%
(139/160)
|
80.9%
(930/1150)
|
package cloud.mindbox.mobile_sdk.inapp.presentation
import cloud.mindbox.mobile_sdk.InitializeLock
import cloud.mindbox.mobile_sdk.Mindbox
import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi
import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto
import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto
import cloud.mindbox.mobile_sdk.inapp.data.managers.FEATURE_TOGGLE_DEFAULT
import cloud.mindbox.mobile_sdk.inapp.data.managers.InAppWebViewLearnedHostsStore
import cloud.mindbox.mobile_sdk.inapp.data.managers.PREWARM_INAPP_WEBVIEW_FEATURE
import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator
import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager
import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager
import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator
import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig
import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType
import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer
import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmEngine
import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmLayer
import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmPlanner
import cloud.mindbox.mobile_sdk.inapp.webview.WebViewController
import cloud.mindbox.mobile_sdk.logger.mindboxLogI
import cloud.mindbox.mobile_sdk.logger.mindboxLogW
import cloud.mindbox.mobile_sdk.managers.DbManager
import cloud.mindbox.mobile_sdk.managers.GatewayManager
import cloud.mindbox.mobile_sdk.models.Configuration
import cloud.mindbox.mobile_sdk.models.getShortUserAgent
import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponseBlank
import cloud.mindbox.mobile_sdk.repository.MindboxPreferences
import cloud.mindbox.mobile_sdk.utils.loggingRunCatching
import cloud.mindbox.mobile_sdk.utils.loggingRunCatchingSuspending
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONTokener
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.time.Duration.Companion.milliseconds
/**
* Production prewarm for webview in-apps. Two stages, both driven by the mobile
* config (no hardcoded hosts or URLs):
*
* 1. SDK init — head start from the previous launch's cached config;
* 2. config downloaded/parsed — [prewarmResources] (releases the warm instance
* when the config proves there are no webview in-apps).
*
* The resource prewarm loads a preconnect page (origins from the config layers +
* API domain + hosts learned from previous shows) and then the layer's real content
* page with the official prewarm params on its URL, so the shared HTTP cache and
* connection pool are warm before the first show. A real show always preempts the prewarm
* ([onRealShowWillStart]): the hidden WebView is destroyed and the network is
* handed over. Unlike iOS there is no instance reuse — Android shares the renderer
* process, so a warm instance buys nothing (measured).
*/
internal interface InAppWebViewPrewarmManager {
/** Prewarm stage 1: head start from the cached config. Call once at SDK init. */
fun prewarmOnInit()
/** Prewarm stage 2: warm what [config]'s webview in-apps need (or release when none). */
fun prewarmResources(config: InAppConfig)
/**
* A real WEBVIEW show is starting: abort the prewarm and free its WebView.
*
* Deliberately narrow — image/snackbar shows do not preempt: their downloads are small
* next to the settle window (network-idle release frees the WebView within seconds),
* while aborting here is terminal and would forfeit the whole launch's byendpoint warm
* because an unrelated banner happened to show first.
*/
fun onRealShowWillStart()
/** Records the https hosts the shown page actually used (learned-hosts store). */
@OptIn(InternalMindboxApi::class)
fun captureObservedHosts(controller: WebViewController)
/**
* Terminates the prewarm subsystem for good. Call before cancelling the coroutine scope
* this manager runs on (SDK teardown, soft reinitialization): a settle-poll job killed by
* scope cancellation never reaches its own tail-end `engine.release()`, so without this
* the warm WebView would leak until process death.
*/
fun terminate()
}
@OptIn(InternalMindboxApi::class)
internal class InAppWebViewPrewarmManagerImpl(
private val engine: InAppWebViewPrewarmEngine,
private val mobileConfigSerializationManager: MobileConfigSerializationManager,
private val gatewayManager: Lazy<GatewayManager>,
private val inAppValidator: InAppValidator,
private val webViewLayerValidator: WebViewLayerValidator,
private val learnedHostsStore: InAppWebViewLearnedHostsStore,
private val featureToggleManager: FeatureToggleManager,
private val webViewCachePolicy: InAppWebViewCachePolicy
) : InAppWebViewPrewarmManager {
companion object {
// Hard cap on how long the hidden WebView may live after the content page was
// handed to it; normally the network-idle poll below releases it much earlier
// (cache/sockets survive at the profile level, so keeping it alive buys nothing).
private const val SETTLE_RELEASE_MS = 30_000L
// Network-idle release: the page is considered settled when the Resource Timing
// entry count is stable across consecutive polls AND the last completed resource
// finished at least IDLE_QUIET_MS ago. Entries appear only on completion, so the
// quiet window (not the stable count alone) is what guards against an in-flight
// download; a transfer slower than the window can still be cut — same worst case
// as the hard cap, the show just re-fetches that file.
private const val IDLE_POLL_MS = 1_000L
private const val IDLE_QUIET_MS = 2_000L
private const val IDLE_STABLE_POLLS = 2
// Returns "<entryCount>:<msSinceLastResponseEnd>" (or "" on any error).
private const val IDLE_PROBE_JS =
"(function(){try{var e=performance.getEntriesByType('resource');var l=0;" +
"for(var i=0;i<e.length;i++){var r=e[i].responseEnd;if(r>l)l=r}" +
"return e.length+':'+Math.round(performance.now()-l)}catch(t){return''}})()"
}
private val hasStartedResourcePrewarm = AtomicBoolean(false)
private val hasAborted = AtomicBoolean(false)
// Set when the freshest config proved there is nothing to warm: an init prewarm still
// suspended in the content fetch must not resurrect a WebView the config just retired.
private val latestConfigHasNoLayers = AtomicBoolean(false)
private val settleJob = AtomicReference<Job?>(null)
override fun prewarmOnInit() {
// Cheap short-circuit before paying for the config read/parse below: a repeat
// initialize() call must not redo work a prior attempt already finished.
if (hasStartedResourcePrewarm.get()) return
Mindbox.mindboxScope.launch {
loggingRunCatchingSuspending {
// Other init-time readers wait for this; without it, a migration that fails
// and triggers a softReset could erase the cached config out from under a
// prewarm that already read it.
InitializeLock.await(InitializeLock.State.MIGRATION)
val cachedConfig = MindboxPreferences.inAppConfig
if (cachedConfig.isBlank()) {
// Nothing to prewarm, but the cache toggle still needs a decision for
// the first real show — latch it to the default now, off the main
// thread, instead of parsing lazily (nothing to parse anyway) the
// moment isCacheEnabled is first read during that show.
webViewCachePolicy.prime(null)
return@loggingRunCatchingSuspending
}
val layers = webViewLayers(cachedConfig)
if (layers.isEmpty()) return@loggingRunCatchingSuspending
mindboxLogI("[WebView] Prewarm: head start from cached config (${layers.size} webview layer(s))")
startResourcePrewarm(layers)
}
}
}
override fun prewarmResources(config: InAppConfig) {
// A fresh config that turns the toggle off also kills a stage-1 instance started
// under the previous launch's config.
if (!featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE)) {
mindboxLogI("[WebView] Prewarm: feature toggle is off, releasing warm WebView")
releaseWarmWebView()
return
}
val layers = config.inApps
.flatMap { inApp -> inApp.form.variants }
.filterIsInstance<InAppType.WebView>()
.flatMap { webView -> webView.layers }
.filterIsInstance<Layer.WebViewLayer>()
.map { layer -> InAppWebViewPrewarmLayer(baseUrl = layer.baseUrl, contentUrl = layer.contentUrl) }
if (layers.isEmpty()) {
mindboxLogI("[WebView] Prewarm: config has no webview in-apps, releasing warm WebView")
releaseWarmWebView()
return
}
latestConfigHasNoLayers.set(false)
Mindbox.mindboxScope.launch {
loggingRunCatchingSuspending {
startResourcePrewarm(layers)
}
}
}
private fun releaseWarmWebView() {
latestConfigHasNoLayers.set(true)
settleJob.getAndSet(null)?.cancel()
engine.release()
}
override fun onRealShowWillStart() = abortPermanently()
override fun terminate() = abortPermanently()
private fun abortPermanently() {
hasAborted.set(true)
settleJob.getAndSet(null)?.cancel()
// Terminal: the engine latches synchronously, so a prewarm load already posted from
// a background thread cannot resurrect the WebView afterward.
engine.abort()
}
override fun captureObservedHosts(controller: WebViewController) {
controller.evaluateJavaScript(InAppWebViewPrewarmPlanner.observedResourceHostsScript) { result ->
val observedHosts = parseObservedHosts(result)
if (observedHosts.isEmpty()) return@evaluateJavaScript
Mindbox.mindboxScope.launch {
loggingRunCatchingSuspending {
val endpointId = currentConfiguration()?.endpointId ?: return@loggingRunCatchingSuspending
learnedHostsStore.merge(endpointId, observedHosts)
mindboxLogI("[WebView] Prewarm: learned hosts for $endpointId: $observedHosts")
}
}
}
}
/**
* Runs at most once per process — but only an attempt that actually reaches the engine
* consumes the one-shot: a transient configuration read failure or an unplannable
* cached config must not block a later attempt from a valid fresh config.
*
* By design the one-shot also means stage 2 does NOT re-warm when stage 1 already ran
* from a cached config whose URLs have since changed — the head start beats freshness
* for this launch, and the next launch heals with the new cached config.
*/
private suspend fun startResourcePrewarm(layers: List<InAppWebViewPrewarmLayer>) {
if (hasAborted.get()) return
val configuration = currentConfiguration() ?: run {
mindboxLogW("[WebView] Prewarm: no saved configuration, skipping")
return
}
val plan = InAppWebViewPrewarmPlanner.buildPlan(
layers = layers,
extraOrigins = listOf(configuration.domain) + learnedHostsStore.hosts(configuration.endpointId)
) ?: run {
mindboxLogW("[WebView] Prewarm: no valid webview layer urls in config, skipping")
return
}
// Re-check after the configuration read suspension BEFORE the one-shot CAS: an
// attempt that stumbles here must not consume it, or a fresh no-layers config
// landing mid-suspension would burn the one-shot on a prewarm that never reaches
// 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}")
engine.loadPreconnectPage(plan.preconnectHtml, plan.baseUrl, userAgentSuffix)
val html = runCatching { gatewayManager.value.fetchWebViewContent(plan.contentUrl) }
.getOrElse { error ->
mindboxLogW("[WebView] Prewarm: content page fetch failed: $error")
scheduleSettleRelease()
return
}
// Re-check both verdicts after the suspension point: a real show may have taken the
// network over, or a fresh config may have proven there is nothing to warm — either
// way the fetched content must not resurrect a WebView. The no-layers path must
// also RELEASE: the preconnect load above may have already created the WebView, and
// with no settle poll scheduled on this path nothing else would ever free it.
if (hasAborted.get()) return
if (latestConfigHasNoLayers.get()) {
engine.release()
return
}
// Official prewarm contract on the document URL: a runtime that knows it boots
// tracker-only; an older runtime ignores it (plain page warm, no byendpoint).
val prewarmBaseUrl = InAppWebViewPrewarmPlanner.prewarmContentBaseUrl(
baseUrl = plan.baseUrl,
endpointId = configuration.endpointId,
deviceUuid = MindboxPreferences.deviceUuid
)
mindboxLogI("[WebView] Prewarm: content page under $prewarmBaseUrl, endpoint ${configuration.endpointId}")
engine.loadContentPage(
html = html,
baseUrl = prewarmBaseUrl,
userAgentSuffix = userAgentSuffix
)
scheduleSettleRelease()
}
private fun scheduleSettleRelease() {
// A terminal preempt may have landed while the caller was suspended — never store a
// poll job into the slot onRealShowWillStart() just cleared (it would probe the main
// looper for 30s during the live show).
if (hasAborted.get()) return
val job = Mindbox.mindboxScope.launch {
// Belt-and-suspenders with the per-tick check below: an abort landing between
// the guard above and this coroutine's first resumption must not run even one
// poll tick.
if (hasAborted.get()) return@launch
// Budget in poll units instead of a wall clock read: the whole loop runs on
// virtual time in tests. A null probe is charged the full probe timeout so the
// cap stays a real wall-clock bound even when the evaluate callback never fires
// (blocked main thread, renderer stall — the pathological cases the cap exists
// for). A fast-but-garbage probe gets overcharged and releases early, which is
// the safe direction: garbage means the page or WebView is not answering.
val budgetPolls = (SETTLE_RELEASE_MS / IDLE_POLL_MS).toInt()
val timeoutCharge = (IDLE_QUIET_MS / IDLE_POLL_MS).toInt()
var polls = 0
var lastCount = -1
var stablePolls = 0
var idle = false
while (polls < budgetPolls) {
delay(IDLE_POLL_MS.milliseconds)
polls++
if (hasAborted.get()) return@launch
val probe = probeResourceState()
if (probe == null) {
polls += timeoutCharge
continue
}
if (probe.entryCount == lastCount) {
stablePolls++
} else {
stablePolls = 0
lastCount = probe.entryCount
}
if (stablePolls >= IDLE_STABLE_POLLS && probe.msSinceLastResponseEnd > IDLE_QUIET_MS) {
idle = true
break
}
}
mindboxLogI(
"[WebView] Prewarm: settle release after ~${polls * IDLE_POLL_MS}ms of budget " +
if (idle) "(network idle, $lastCount resources)" else "(hard cap)"
)
engine.release()
}
settleJob.getAndSet(job)?.cancel()
if (hasAborted.get()) {
settleJob.getAndSet(null)?.cancel()
}
}
private data class ResourceProbe(val entryCount: Int, val msSinceLastResponseEnd: Long)
/**
* One Resource Timing probe on the prewarm page. Null when the probe cannot run or
* returns garbage (WebView gone/aborted, page not ready) — callers just keep polling
* until the hard cap. The 2s timeout guards against a callback that never fires.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun probeResourceState(): ResourceProbe? =
withTimeoutOrNull(IDLE_QUIET_MS.milliseconds) {
suspendCancellableCoroutine { continuation ->
engine.evaluateJavaScript(IDLE_PROBE_JS) { rawResult ->
// evaluateJavascript JSON-quotes string results: "\"6:3456\"".
val parts = rawResult?.trim('"')?.split(':')
val probe = if (parts?.size == 2) {
val count = parts[0].toIntOrNull()
val sinceLast = parts[1].toLongOrNull()
if (count != null && sinceLast != null) ResourceProbe(count, sinceLast) else null
} else {
null
}
if (continuation.isActive) continuation.resume(probe) {}
}
}
}
private suspend fun currentConfiguration(): Configuration? =
runCatching { DbManager.listenConfigurations().first() }.getOrNull()
/**
* Light parse of the cached config: only webview layer urls, no targeting checks.
*
* The toggle is read from THIS cached config, not [featureToggleManager]: stage 1 races
* the fresh config download, so the manager may still hold last launch's (or no) state
* when this runs.
*/
private fun webViewLayers(configString: String): List<InAppWebViewPrewarmLayer> {
val configBlank = mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString)
?: return emptyList()
// Shares this parse with the cache toggle instead of it deserializing the same
// cached config a second time; a no-op once the toggle has already latched.
webViewCachePolicy.prime(configBlank)
if (!isPrewarmEnabled(configBlank)) {
mindboxLogI("[WebView] Prewarm: feature toggle is off in the cached config, skipping head start")
return emptyList()
}
return configBlank.inApps.orEmpty()
// Same version gate as the real pipeline: in-apps for other SDK versions may
// carry form formats this version cannot even deserialize.
.asSequence()
.filter { inAppBlank -> inAppValidator.validateInAppVersion(inAppBlank) }
.flatMap { inAppBlank ->
mobileConfigSerializationManager.deserializeToInAppFormDto(inAppBlank.form)
?.variants.orEmpty()
.filterIsInstance<PayloadDto.ModalWindowDto>()
// Same gate as the real pipeline (InAppMapper): a modal only becomes a
// WebView in-app when webview is its FIRST layer — collecting every
// webview layer regardless of position would prewarm modals that will
// never actually show as a webview, burning the one-shot on them.
.mapNotNull { modal -> modal.content?.background?.layers?.firstOrNull() }
}
.filterIsInstance<BackgroundDto.LayerDto.WebViewLayerDto>()
.filter { layerDto -> webViewLayerValidator.isValid(layerDto) }
.map { layerDto -> InAppWebViewPrewarmLayer(baseUrl = layerDto.baseUrl, contentUrl = layerDto.contentUrl) }
.toList()
}
private fun isPrewarmEnabled(configBlank: InAppConfigResponseBlank): Boolean =
configBlank.settings?.featureToggles?.toggles?.get(PREWARM_INAPP_WEBVIEW_FEATURE) ?: FEATURE_TOGGLE_DEFAULT
/**
* `evaluateJavascript` returns the JS value JSON-encoded; the probe returns a string
* containing a JSON array, so unwrap the outer string and then parse the array.
*/
private fun parseObservedHosts(result: String?): List<String> = loggingRunCatching(defaultValue = emptyList()) {
if (result.isNullOrBlank() || result == "null") return@loggingRunCatching emptyList()
val unwrapped = JSONTokener(result).nextValue() as? String ?: return@loggingRunCatching emptyList()
val array = JSONArray(unwrapped)
(0 until array.length()).mapNotNull { index ->
array.optString(index).takeIf { host -> host.isNotBlank() }
}
}
}