From 5174816dccf716bfe8ed24ededd51fbf1fb21812 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:36:45 +0300 Subject: [PATCH 1/7] MOBILE-269: Webview cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MOBILE-0000: Bump kmp submodule (drain evaluate callbacks before destroy) MOBILE-0000: Cover the stranded-preconnect release and cap budget with tests MOBILE-0000: Release the stranded preconnect WebView and honest-cap the settle poll A no-layers config landing while the init prewarm was suspended latched the verdict but the resumed prewarm bare-returned after the fetch: the preconnect load had already created a WebView and no settle poll would ever free it. The verdict is now re-checked before the preconnect load and the post-fetch path releases. Also: null probes are charged the probe timeout so the settle cap is a real wall-clock bound; ready-check give-up defers to the init timer instead of closing; teardown-race evaluate misses no longer feed telemetry; GatewayManager is injected lazily (Volley was constructed on the main thread during SDK init); learned-hosts merge is synchronized; kmp submodule bump (destroy fix, prewarm navigation pinning, httpsOrigin charset). MOBILE-0000: Cover the settle-poll guards, one-shot survival and hard cap MOBILE-0000: Harden the prewarm service and show holder after the audit - the settle poll checks the terminal-preempt latch before it is stored and on every tick — a real show could otherwise inherit a 30s zombie poll in the slot onRealShowWillStart() just cleared; - a no-layers config latches: an init prewarm still suspended in the content fetch can no longer resurrect a WebView the config retired; - the one-shot is consumed only by attempts that reach the engine, so a transient configuration read failure cannot block a later valid warm; - the poll budget is counted in ticks, not wall clock (same 30s, fully virtual-time testable); - onClose detaches the event listener and startReadyCheck refuses a torn-down holder — a late onPageFinished queued on the main looper produced a spurious presentation failure and a second close; - controller.destroy() is called once, not twice; - checkEvaluateJavaScript tracks a failed outgoing call but no longer force-closes the in-app — that is the caller's policy, and one transient miss used to tear down a healthy show. MOBILE-0000: Cover the network-idle settle release MOBILE-0000: Release the prewarm WebView by network idle, not a blind timer The hidden instance lived a fixed 30s after the content page load; a Resource Timing poll (stable entry count + 2s quiet window) releases it as soon as the page has settled — ~3s in practice. The 30s stays as a hard cap: entries appear only on completion, so a transfer slower than the quiet window can still be cut, same worst case as the cap. MOBILE-0000: Cover the WebView readiness probe retry MOBILE-0000: Retry the WebView readiness probe before closing the in-app onPageFinished can fire before the page's module scripts have evaluated (slow device, cold cache), and the single-shot bridge check then closed a healthy in-app. The probe now polls briefly (WebViewReadyChecker, mirrors the iOS fix); the outgoing-message verification stays single-shot — that path talks to a page that already proved itself ready. MOBILE-0000: Update prewarm service tests for the stub-less engine MOBILE-0000: Drop the legacy prewarm stub bridge Bumps kmp-common: the engine loads the content page without the stub JavascriptInterface; old pages degrade to a plain page warm until the web prewarm mode is deployed. MOBILE-0000: Update prewarm service tests for the params contract MOBILE-0000: Load the prewarm content page with official prewarm params A web runtime that knows the contract boots tracker-only from the document URL params; older runtimes ignore them and keep using the engine's legacy stub bridge. Bumps kmp-common with prewarmContentBaseUrl and the stub-call logging. MOBILE-0000: Preempt prewarm terminally + thread-safe settle job Real-show preemption now calls engine.abort() (synchronous latch closes the race where an already-posted prewarm load recreated the WebView mid-show and, with the settle job cancelled, kept it alive forever). settleJob becomes an AtomicReference. Bump kmp-common. MOBILE-0000: Gate prewarm light parse by sdkVersion like the real pipeline The endpoint config carries webview-typed form variants for newer SDK versions; without the version gate the head-start parse logs a JsonParseException per such in-app on every launch. MOBILE-0000: Wire production WebView prewarm service into SDK lifecycle InAppWebViewPrewarmService: stage 1 at init (head start from cached config, light layer parse), stage 2 after each config parse in MobileConfigRepositoryImpl (release when no webview in-apps), preemption from WebViewInAppViewHolder before a real show, learned-hosts capture at bridge close feeding next launch's preconnect. Everything derived from config + API domain; no hardcoded hosts. Bump kmp-common with cache/prewarm primitives. MOBILE-0000: Prewarm WebView at SDK init + bump kmp-common Call MindboxWebViewLab.prewarm() from Mindbox.initialize() to warm the renderer process/connections early. Bump kmp-common-sdk submodule to the prototype branch carrying the cache+preconnect prototype. Throwaway measurement scaffolding. --- .gitmodules | 2 +- kmp-common-sdk | 2 +- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 3 + .../mobile_sdk/di/modules/DataModule.kt | 23 +- .../mobile_sdk/di/modules/MindboxModule.kt | 1 + .../managers/InAppWebViewLearnedHostsStore.kt | 44 +++ .../MobileConfigRepositoryImpl.kt | 7 +- .../InAppWebViewPrewarmService.kt | 353 +++++++++++++++++ .../view/WebViewInappViewHolder.kt | 81 +++- .../presentation/view/WebViewReadyChecker.kt | 66 ++++ .../InAppWebViewLearnedHostsStoreTest.kt | 57 +++ .../MobileConfigRepositoryImplTest.kt | 3 +- .../InAppWebViewPrewarmServiceImplTest.kt | 373 ++++++++++++++++++ .../view/WebViewReadyCheckerTest.kt | 106 +++++ 14 files changed, 1109 insertions(+), 12 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt diff --git a/.gitmodules b/.gitmodules index 8112ba12..db7f9ca9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "kmp-common-sdk"] path = kmp-common-sdk url = https://github.com/mindbox-cloud/kmp-common-sdk.git - branch = develop + branch = prototype/webview-cache-profiling diff --git a/kmp-common-sdk b/kmp-common-sdk index 80e199ff..19df8a93 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 80e199ff1d25f1bed6283287b4a89be6e3e65e10 +Subproject commit 19df8a9382530d74d1d14e52c580ebdc3c08f92f diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 7a6bbdf3..b163c673 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -562,6 +562,9 @@ public object Mindbox : MindboxLog { Stopwatch.start(Stopwatch.INIT_SDK) initComponents(context.applicationContext) + // Prewarm stage 1: head start for webview in-apps from the cached config + // (mirrors the iOS SDK's init-time prewarm; a real show always preempts it). + MindboxDI.appModule.inAppWebViewPrewarmService.prewarmOnInit() pushConverters = selectPushServiceHandler(pushServices) logI( "init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 6695ffa4..92f5225a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -26,9 +26,13 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSer import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmServiceImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmEngine +import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.* import cloud.mindbox.mobile_sdk.managers.MobileConfigSettingsManagerImpl import cloud.mindbox.mobile_sdk.managers.RequestPermissionManager @@ -145,6 +149,22 @@ internal fun DataModule( ) } + override val inAppWebViewPrewarmService: InAppWebViewPrewarmService by lazy { + InAppWebViewPrewarmServiceImpl( + engine = InAppWebViewPrewarmEngine(appContext) { message -> + mindboxLogI("[WebView] Prewarm: $message") + }, + mobileConfigSerializationManager = mobileConfigSerializationManager, + // Lazy on purpose: this service is resolved on the main thread during SDK init + // (prewarm stage 1), and constructing GatewayManager spins up Volley (thread + // pool + cache-dir I/O) — defer that to the background fetch that needs it. + gatewayManager = lazy { gatewayManager }, + inAppValidator = inAppValidator, + webViewLayerValidator = webViewLayerValidator, + learnedHostsStore = InAppWebViewLearnedHostsStore() + ) + } + override val mobileConfigRepository: MobileConfigRepository by lazy { MobileConfigRepositoryImpl( inAppMapper = inAppMapper, @@ -163,7 +183,8 @@ internal fun DataModule( mobileConfigSettingsManager = mobileConfigSettingsManager, integerPositiveValidator = integerPositiveValidator, inappSettingsManager = inappSettingsManager, - featureToggleManager = featureToggleManager + featureToggleManager = featureToggleManager, + inAppWebViewPrewarmService = inAppWebViewPrewarmService ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index 4d5f8499..f03e5746 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -64,6 +64,7 @@ internal interface DataModule : MindboxModule { val sessionStorageManager: SessionStorageManager val mobileConfigRepository: MobileConfigRepository val mobileConfigSerializationManager: MobileConfigSerializationManager + val inAppWebViewPrewarmService: InAppWebViewPrewarmService val inAppGeoRepository: InAppGeoRepository val inAppRepository: InAppRepository val callbackRepository: CallbackRepository diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt new file mode 100644 index 00000000..b4f8fd58 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt @@ -0,0 +1,44 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.managers.SharedPreferencesManager +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import org.json.JSONArray + +/** + * Persists https hosts actually observed during webview in-app shows (per endpoint). + * The mobile config only reveals the bootstrap hosts; the heavy ones (image CDNs) + * are discovered at show time and fed to the next launch's preconnect prewarm. + */ +internal class InAppWebViewLearnedHostsStore { + + companion object { + private const val KEY_PREFIX = "MBInAppWebViewLearnedHosts." + private const val MAX_HOSTS = 12 + } + + fun hosts(endpointId: String): List = loggingRunCatching(defaultValue = emptyList()) { + val raw = SharedPreferencesManager.getString(key(endpointId)) + ?.takeIf { it.isNotBlank() } + ?: return@loggingRunCatching emptyList() + val array = JSONArray(raw) + (0 until array.length()).mapNotNull { index -> + array.optString(index).takeIf { host -> host.isNotBlank() } + } + } + + /** + * Newest-first merge capped at [MAX_HOSTS] so one weird show can't flood the list. + * Synchronized: merges arrive from evaluate callbacks/coroutines of concurrent closes, + * and an unsynchronized read-modify-write would silently drop one close's hosts. + */ + @Synchronized + fun merge(endpointId: String, observedHosts: List): Unit = loggingRunCatching { + if (endpointId.isBlank()) return@loggingRunCatching + val incoming = observedHosts.map(String::trim).filter(String::isNotBlank) + if (incoming.isEmpty()) return@loggingRunCatching + val merged = (incoming + hosts(endpointId)).distinct().take(MAX_HOSTS) + SharedPreferencesManager.put(key(endpointId), JSONArray(merged).toString()) + } + + private fun key(endpointId: String): String = KEY_PREFIX + endpointId +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 42abb68d..8c1a13ae 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -13,6 +13,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfi 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.InAppTtlData +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI @@ -51,7 +52,8 @@ internal class MobileConfigRepositoryImpl( private val mobileConfigSettingsManager: MobileConfigSettingsManager, private val integerPositiveValidator: IntegerPositiveValidator, private val inappSettingsManager: InappSettingsManager, - private val featureToggleManager: FeatureToggleManager + private val featureToggleManager: FeatureToggleManager, + private val inAppWebViewPrewarmService: InAppWebViewPrewarmService ) : MobileConfigRepository { private val mutex = Mutex() @@ -105,6 +107,9 @@ internal class MobileConfigRepositoryImpl( featureToggleManager.applyToggles(config = filteredConfig) persistOperationsDomain(filteredConfig) configState.value = updatedInAppConfig + // Prewarm stage 2: warm what the config's webview in-apps will need + // (or release the warm instance when the config proves there are none). + inAppWebViewPrewarmService.prewarmResources(updatedInAppConfig) mindboxLogI(message = "Providing config: $updatedInAppConfig") } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt new file mode 100644 index 00000000..e4ea0e39 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt @@ -0,0 +1,353 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +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.InAppWebViewLearnedHostsStore +import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator +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.MindboxWebViewLab +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.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import cloud.mindbox.mobile_sdk.utils.loggingRunCatchingSuspending +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 + +/** + * 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 InAppWebViewPrewarmService { + + /** 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). */ + fun captureObservedHosts(controller: WebViewController) +} + +@OptIn(InternalMindboxApi::class) +internal class InAppWebViewPrewarmServiceImpl( + private val engine: InAppWebViewPrewarmEngine, + private val mobileConfigSerializationManager: MobileConfigSerializationManager, + private val gatewayManager: Lazy, + private val inAppValidator: InAppValidator, + private val webViewLayerValidator: WebViewLayerValidator, + private val learnedHostsStore: InAppWebViewLearnedHostsStore +) : InAppWebViewPrewarmService { + + 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 ":" (or "" on any error). + private const val IDLE_PROBE_JS = + "(function(){try{var e=performance.getEntriesByType('resource');var l=0;" + + "for(var i=0;il)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(null) + + override fun prewarmOnInit() { + if (!MindboxWebViewLab.PREWARM_ENABLED) return // MEASUREMENT (throwaway) gate + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + val cachedConfig = MindboxPreferences.inAppConfig + if (cachedConfig.isBlank()) 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) { + if (!MindboxWebViewLab.PREWARM_ENABLED) return // MEASUREMENT (throwaway) gate + val layers = config.inApps + .flatMap { inApp -> inApp.form.variants } + .filterIsInstance() + .flatMap { webView -> webView.layers } + .filterIsInstance() + .map { layer -> InAppWebViewPrewarmLayer(baseUrl = layer.baseUrl, contentUrl = layer.contentUrl) } + if (layers.isEmpty()) { + mindboxLogI("[WebView] Prewarm: config has no webview in-apps, releasing warm WebView") + latestConfigHasNoLayers.set(true) + settleJob.getAndSet(null)?.cancel() + engine.release() + return + } + latestConfigHasNoLayers.set(false) + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + startResourcePrewarm(layers) + } + } + } + + override fun onRealShowWillStart() { + 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 mid-show. + 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) { + 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 + } + if (!hasStartedResourcePrewarm.compareAndSet(false, true)) return + // Re-check after the configuration read suspension: a fresh no-layers config that + // landed while this (init-triggered) prewarm was suspended must keep the WebView + // from ever being created — release() posted at that point was a no-op. + if (hasAborted.get() || latestConfigHasNoLayers.get()) return + val userAgentSuffix = configuration.getShortUserAgent() + + mindboxLogI("[WebView] Prewarm: preconnect to ${plan.preconnectOrigins.joinToString(",")} under ${plan.baseUrl}") + engine.loadPreconnectPage(plan.preconnectHtml, plan.baseUrl, userAgentSuffix) + + if (MindboxWebViewLab.PREWARM_PRECONNECT_ONLY) { // MEASUREMENT (throwaway) gate + scheduleSettleRelease() + return + } + + 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 { + // 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) + 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() + } + + 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. + */ + private suspend fun probeResourceState(): ResourceProbe? = + withTimeoutOrNull(IDLE_QUIET_MS) { + 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. */ + private fun webViewLayers(configString: String): List { + val configBlank = mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) + ?: 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. + .filter { inAppBlank -> inAppValidator.validateInAppVersion(inAppBlank) } + .flatMap { inAppBlank -> + mobileConfigSerializationManager.deserializeToInAppFormDto(inAppBlank.form) + ?.variants.orEmpty() + .filterIsInstance() + .flatMap { modal -> modal.content?.background?.layers.orEmpty() } + } + .filterIsInstance() + .filter { layerDto -> webViewLayerValidator.isValid(layerDto) } + .map { layerDto -> InAppWebViewPrewarmLayer(baseUrl = layerDto.baseUrl, contentUrl = layerDto.contentUrl) } + } + + /** + * `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 = 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() } + } + } +} 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 31cdb714..5a0e42fb 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 @@ -3,6 +3,8 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Activity import android.app.Application import android.net.Uri +import android.os.Handler +import android.os.Looper import android.view.ViewGroup import android.widget.RelativeLayout import android.widget.Toast @@ -22,6 +24,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import androidx.lifecycle.ProcessLifecycleOwner @@ -77,6 +80,8 @@ internal class WebViewInAppViewHolder( private var closeInappTimer: Timer? = null private var webViewController: WebViewController? = null private var currentWebViewOrigin: String? = null + private var readyChecker: WebViewReadyChecker? = null + private val mainHandler: Handler = Handler(Looper.getMainLooper()) private var motionService: MotionServiceProtocol? = null @@ -89,6 +94,7 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val timeProvider: TimeProvider by mindboxInject { timeProvider } + private val webViewPrewarmService: InAppWebViewPrewarmService by mindboxInject { inAppWebViewPrewarmService } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -317,6 +323,8 @@ internal class WebViewInAppViewHolder( private fun handleCloseAction(message: BridgeMessage): String { motionService?.stopMonitoring() + // Remember which https hosts this show actually used — feeds the next launch's preconnect. + webViewController?.let { controller -> webViewPrewarmService.captureObservedHosts(controller) } inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -426,7 +434,7 @@ internal class WebViewInAppViewHolder( override fun onPageFinished(url: String?) { mindboxLogD("onPageFinished: $url") currentWebViewOrigin = resolveOrigin(url) ?: currentWebViewOrigin - webViewController?.evaluateJavaScript(JS_CHECK_BRIDGE, ::checkEvaluateJavaScript) + startReadyCheck(url) } override fun onShouldOverrideUrlLoading(url: String?, isForMainFrame: Boolean?): Boolean { @@ -515,17 +523,70 @@ internal class WebViewInAppViewHolder( } } + /** + * Readiness probe for a freshly finished page. Module scripts can evaluate a beat after + * `onPageFinished` (slow device, cold cache), so a single early `false` must not close a + * healthy in-app — the checker polls before giving up. Every new `onPageFinished` (redirect, + * re-load) restarts the poll; teardown cancels it. The outgoing-message verification in + * [sendActionInternal] intentionally stays single-shot ([checkEvaluateJavaScript]) — that + * path talks to a page that already proved itself ready. + * + * Give-up records the failure but does NOT close: the init timer is the closing + * authority. The checker's ~1.2s budget can expire while an allowed same-origin + * navigation is still in flight or while a slow page is still booting its bridge — + * cases the timer would have accepted (the window stays invisible until `init` anyway). + */ + private fun startReadyCheck(url: String?) { + // A late onPageFinished can be queued on the main looper when the in-app closes; + // a checker started against the torn-down holder would only produce a spurious + // failure event. + if (webViewController == null) return + readyChecker?.cancel() + val checker = WebViewReadyChecker( + evaluate = { script, resultCallback -> + webViewController?.evaluateJavaScript(script, resultCallback) + ?: resultCallback(null) + }, + schedule = { delayMillis, action -> mainHandler.postDelayed(action, delayMillis) } + ) + readyChecker = checker + checker.run( + script = JS_CHECK_BRIDGE, + expectedResult = JS_RETURN, + onReady = { mindboxLogD("JS ready check passed for $url") }, + onGiveUp = { lastFailure -> + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "JS ready check gave up for $url: $lastFailure" + ) + } + ) + } + + /** + * Verifies an outgoing bridge call's JS result. Tracks the failure but does NOT close + * the in-app: whether one undelivered message is fatal is the caller's policy (a failed + * `back` action closes via its own onError, a failed motion event just stops monitoring) + * — force-closing here used to tear down a healthy show over a single transient miss. + * Page readiness has its own retrying probe ([startReadyCheck]). + */ internal fun checkEvaluateJavaScript(response: String?): Boolean { return when (response) { JS_RETURN -> true else -> { - inAppFailureTracker.sendFailureWithContext( - inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "evaluateJavaScript return unexpected response: $response", + // A miss during teardown (holder already closed, controller gone) is an + // expected race, not a presentation failure — don't feed it to telemetry. + if (webViewController != null) { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "evaluateJavaScript return unexpected response: $response", tags = wrapper.tags ) - inAppController.close() + } else { + mindboxLogW("evaluateJavaScript miss after teardown (ignored): $response") + } false } } @@ -605,6 +666,8 @@ internal class WebViewInAppViewHolder( private fun renderLayer(layer: Layer.WebViewLayer) { if (webViewController == null) { + // A real show takes priority: kill the prewarm so it can't compete for bandwidth. + webViewPrewarmService.onRealShowWillStart() val controller: WebViewController = createWebViewController(layer) webViewController = controller @@ -763,14 +826,18 @@ internal class WebViewInAppViewHolder( hapticFeedbackExecutor.cancel() motionService?.stopMonitoring() stopTimer() + readyChecker?.cancel() + readyChecker = null cancelPendingResponses("WebView In-App is closed") webViewController?.let { controller -> + // Detach first: a page event already queued on the main looper must not reach + // this torn-down holder (e.g. a late onPageFinished spawning a ready checker). + controller.setEventListener(null) val view: WebViewPlatformView = controller.view view.parent.safeAs()?.removeView(view) controller.destroy() } currentWebViewOrigin = null - webViewController?.destroy() webViewController = null currentMindboxView = null super.onClose() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt new file mode 100644 index 00000000..7ddf6230 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt @@ -0,0 +1,66 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +/** + * Polls the page for the JS bridge instead of deciding on a single onPageFinished-time probe. + * + * Module scripts can finish evaluating a beat after the page's load event on slow devices or + * with a cold cache, so one early `false` must not close a healthy in-app. The check re-runs + * on a short cadence and gives up only after the full budget; a new navigation (or teardown) + * cancels the previous checker outright. Mirrors the iOS SDK's WebViewReadyChecker. + * + * Pure logic: evaluation and scheduling are injected, so the class is JVM-testable. + */ +internal class WebViewReadyChecker( + private val evaluate: (script: String, resultCallback: (String?) -> Unit) -> Unit, + private val schedule: (delayMillis: Long, action: () -> Unit) -> Unit, +) { + + companion object { + const val MAX_ATTEMPTS: Int = 8 + const val RETRY_DELAY_MS: Long = 150L + } + + @Volatile + private var isCancelled = false + + fun run( + script: String, + expectedResult: String, + onReady: () -> Unit, + onGiveUp: (lastFailure: String) -> Unit + ) { + attempt(1, script, expectedResult, onReady, onGiveUp) + } + + /** + * Abandons the poll without calling either completion — the caller's new navigation + * (or teardown) owns readiness from here. + */ + fun cancel() { + isCancelled = true + } + + private fun attempt( + number: Int, + script: String, + expectedResult: String, + onReady: () -> Unit, + onGiveUp: (String) -> Unit + ) { + if (isCancelled) return + evaluate(script) { result -> + if (isCancelled) return@evaluate + if (result == expectedResult) { + onReady() + return@evaluate + } + if (number >= MAX_ATTEMPTS) { + onGiveUp("evaluateJavaScript returned unexpected response: $result") + return@evaluate + } + schedule(RETRY_DELAY_MS) { + attempt(number + 1, script, expectedResult, onReady, onGiveUp) + } + } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt new file mode 100644 index 00000000..dcf7d82d --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt @@ -0,0 +1,57 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.managers.SharedPreferencesManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class InAppWebViewLearnedHostsStoreTest { + + private val store = InAppWebViewLearnedHostsStore() + + @Before + fun setUp() { + SharedPreferencesManager.with(ApplicationProvider.getApplicationContext()) + SharedPreferencesManager.deleteAll() + } + + @Test + fun `hosts are empty by default`() { + assertTrue(store.hosts("Some.Endpoint").isEmpty()) + } + + @Test + fun `merge persists per endpoint and roundtrips`() { + store.merge("Endpoint.A", listOf("a.mindbox.ru", "b.mindbox.ru")) + store.merge("Endpoint.B", listOf("c.mindbox.ru")) + + assertEquals(listOf("a.mindbox.ru", "b.mindbox.ru"), store.hosts("Endpoint.A")) + assertEquals(listOf("c.mindbox.ru"), store.hosts("Endpoint.B")) + } + + @Test + fun `merge is newest first deduplicated and capped`() { + store.merge("Endpoint.A", (1..10).map { index -> "old$index.ru" }) + store.merge("Endpoint.A", listOf("new1.ru", "old1.ru", "new2.ru")) + + val hosts = store.hosts("Endpoint.A") + assertEquals(12, hosts.size) + assertEquals(listOf("new1.ru", "old1.ru", "new2.ru", "old1.ru").distinct().take(3), hosts.take(3)) + assertTrue(hosts.contains("old9.ru")) + } + + @Test + fun `merge ignores blank input`() { + store.merge("Endpoint.A", listOf(" ", "")) + store.merge("", listOf("host.ru")) + + assertTrue(store.hosts("Endpoint.A").isEmpty()) + assertTrue(store.hosts("").isEmpty()) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt index 4d73a19f..df4dcffc 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt @@ -98,7 +98,8 @@ internal class MobileConfigRepositoryImplTest { mobileConfigSettingsManager = mockk(relaxed = true), integerPositiveValidator = mockk(relaxed = true), inappSettingsManager = mockk(relaxed = true), - featureToggleManager = mockk(relaxed = true) + featureToggleManager = mockk(relaxed = true), + inAppWebViewPrewarmService = mockk(relaxed = true) ) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt new file mode 100644 index 00000000..0a2cc280 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt @@ -0,0 +1,373 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadBlankDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl +import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator +import cloud.mindbox.mobile_sdk.inapp.domain.models.Form +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.managers.DbManager +import cloud.mindbox.mobile_sdk.managers.GatewayManager +import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.InAppStub +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.Constants +import cloud.mindbox.mobile_sdk.utils.RuntimeTypeAdapterFactory +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class InAppWebViewPrewarmServiceImplTest { + + private val configuration = Configuration( + previousInstallationId = "", + previousDeviceUUID = "", + endpointId = "Test.Endpoint", + domain = "api.mindbox.ru", + packageName = "test.package", + versionName = "1.0", + versionCode = "1", + subscribeCustomerIfCreated = false, + shouldCreateCustomer = false + ) + + private val webViewInApp = InAppStub.getInApp().copy( + form = Form( + variants = listOf( + InAppType.WebView( + inAppId = "id", + type = "webview", + layers = listOf( + Layer.WebViewLayer( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://mobile-static.mindbox.ru/content/index.html", + type = "webview", + params = emptyMap() + ) + ) + ) + ) + ) + ) + + private lateinit var engine: InAppWebViewPrewarmEngine + private lateinit var gatewayManager: GatewayManager + private lateinit var service: InAppWebViewPrewarmServiceImpl + + @Before + fun setUp() { + mockkObject(Mindbox) + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher()) + mockkObject(DbManager) + every { DbManager.listenConfigurations() } returns flowOf(configuration) + mockkObject(MindboxPreferences) + every { MindboxPreferences.deviceUuid } returns "test-device-uuid" + engine = mockk(relaxed = true) + gatewayManager = mockk(relaxed = true) + coEvery { gatewayManager.fetchWebViewContent(any()) } returns "" + service = InAppWebViewPrewarmServiceImpl( + engine = engine, + mobileConfigSerializationManager = MobileConfigSerializationManagerImpl(gson = configGson()), + gatewayManager = lazyOf(gatewayManager), + inAppValidator = mockk(relaxed = true) { + every { validateInAppVersion(any()) } returns true + }, + webViewLayerValidator = WebViewLayerValidator(), + learnedHostsStore = mockk(relaxed = true) { + every { hosts(any()) } returns listOf("learned-cdn.mindbox.ru") + } + ) + } + + @After + fun tearDown() { + unmockkObject(Mindbox) + unmockkObject(DbManager) + unmockkObject(MindboxPreferences) + } + + private fun configWith(vararg inApps: cloud.mindbox.mobile_sdk.inapp.domain.models.InApp) = InAppConfig( + inApps = inApps.toList(), + monitoring = emptyList(), + operations = emptyMap(), + abtests = emptyList() + ) + + @Test + fun `prewarmResources loads preconnect and content page once`() { + service.prewarmResources(configWith(webViewInApp)) + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { + engine.loadPreconnectPage( + html = match { html -> + html.contains("https://mobile-static.mindbox.ru") && + html.contains("https://api.mindbox.ru") && + html.contains("https://learned-cdn.mindbox.ru") + }, + baseUrl = "https://inapp.local/popup", + userAgentSuffix = any() + ) + } + coVerify(exactly = 1) { gatewayManager.fetchWebViewContent("https://mobile-static.mindbox.ru/content/index.html") } + verify(exactly = 1) { + engine.loadContentPage( + html = "", + // Official prewarm contract: the content page's document URL carries the params. + baseUrl = "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + userAgentSuffix = any() + ) + } + } + + @Test + fun `prewarm webview is released by network idle well before the hard cap`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // Page reports a stable resource list whose last download finished long ago. + every { engine.evaluateJavaScript(any(), any()) } answers { + secondArg<(String?) -> Unit>().invoke("\"6:5000\"") + } + + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.release() } + + // Two stable polls after the baseline one -> idle at the third second, not at 30s. + advanceTimeBy(3_100) + verify(exactly = 1) { engine.release() } + verify(exactly = 0) { engine.abort() } + } + + @Test + fun `garbage probes are charged the probe timeout and drain the cap budget early`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + every { engine.evaluateJavaScript(any(), any()) } answers { + secondArg<(String?) -> Unit>().invoke("\"\"") + } + + service.prewarmResources(configWith(webViewInApp)) + + // Each 1s poll yields a garbage (null) probe charged the 2s probe timeout on top of + // its own second, so the 30-unit budget drains after 10 polls — the cap is a + // wall-clock bound even when the page never answers usefully. + advanceTimeBy(9_500) + verify(exactly = 0) { engine.release() } + advanceTimeBy(1_000) + verify(exactly = 1) { engine.release() } + } + + @Test + fun `a real show during the settle poll stops the poller at the next tick`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // The page keeps downloading (entry count grows every poll), so the poll never + // goes idle by itself. + var count = 0 + every { engine.evaluateJavaScript(any(), any()) } answers { + count++ + secondArg<(String?) -> Unit>().invoke("\"$count:100\"") + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(2_500) + service.onRealShowWillStart() + advanceTimeBy(60_000) + + // The poller stops (job cancelled; the per-tick hasAborted guard is the backstop): + // the terminal abort stays the only teardown, no second release lands mid-show. + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.release() } + } + + @Test + fun `terminal preempt during the content fetch prevents both the content load and the settle poll`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // The real show starts while the prewarm is suspended in the content fetch. + coEvery { gatewayManager.fetchWebViewContent(any()) } coAnswers { + service.onRealShowWillStart() + "" + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(31_000) + + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + // Only the terminal abort released the engine; no zombie poll produced a second one. + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.release() } + } + + @Test + fun `a no-layers config arriving mid-fetch stops the content load from resurrecting a webview`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { gatewayManager.fetchWebViewContent(any()) } coAnswers { + service.prewarmResources(configWith(InAppStub.getInApp())) + "" + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(31_000) + + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + // The resumed prewarm must RELEASE, not bare-return: its preconnect load already + // created a WebView, and this path schedules no settle poll to free it later. + // (First release comes from the no-layers branch itself, second from the resume.) + verify(exactly = 2) { engine.release() } + } + + @Test + fun `a no-layers config arriving before the preconnect load keeps the webview from being created`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // The fresh no-layers config lands while the prewarm is suspended reading the + // saved configuration — before it has touched the engine at all. + every { DbManager.listenConfigurations() } answers { + service.prewarmResources(configWith(InAppStub.getInApp())) + flowOf(configuration) + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(31_000) + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `an attempt without a saved configuration does not consume the one-shot`() { + every { DbManager.listenConfigurations() } returnsMany listOf( + kotlinx.coroutines.flow.emptyFlow(), + flowOf(configuration) + ) + + // First attempt: configuration read fails -> skipped, one-shot must survive. + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + + // Second attempt with a readable configuration warms normally. + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + + @Test + fun `prewarmResources releases warm webview when config has no webview inapps`() { + service.prewarmResources(configWith(InAppStub.getInApp())) + + verify(exactly = 1) { engine.release() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `real show preempts prewarm terminally and blocks later attempts`() { + service.onRealShowWillStart() + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `prewarmOnInit warms from cached config without validation pipeline`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson() + + service.prewarmOnInit() + + verify(exactly = 1) { engine.loadPreconnectPage(any(), "https://inapp.local/popup", any()) } + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + + @Test + fun `prewarmOnInit does nothing without cached config`() { + every { MindboxPreferences.inAppConfig } returns "" + + service.prewarmOnInit() + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + } + + /** Same `${'$'}type` adapters the production gson registers for the form payload path. */ + private fun configGson(): Gson = GsonBuilder() + .registerTypeAdapterFactory( + RuntimeTypeAdapterFactory + .of(PayloadBlankDto::class.java, Constants.TYPE_JSON_NAME, true) + .registerSubtype(PayloadBlankDto.ModalWindowBlankDto::class.java, PayloadDto.ModalWindowDto.MODAL_JSON_NAME) + .registerSubtype(PayloadBlankDto.SnackBarBlankDto::class.java, PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME) + ) + .registerTypeAdapterFactory( + RuntimeTypeAdapterFactory + .of(BackgroundDto.LayerDto::class.java, Constants.TYPE_JSON_NAME, true) + .registerSubtype( + BackgroundDto.LayerDto.ImageLayerDto::class.java, + BackgroundDto.LayerDto.ImageLayerDto.IMAGE_TYPE_JSON_NAME + ) + .registerSubtype( + BackgroundDto.LayerDto.WebViewLayerDto::class.java, + BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME + ) + ) + .create() + + private fun cachedConfigJson(): String = """ + { + "inapps": [ + { + "id": "cached-webview", + "sdkVersion": { "min": 1, "max": null }, + "targeting": { "${'$'}type": "true" }, + "form": { + "variants": [ + { + "${'$'}type": "modal", + "content": { + "background": { + "layers": [ + { + "${'$'}type": "webview", + "baseUrl": "https://inapp.local/popup", + "contentUrl": "https://mobile-static.mindbox.ru/content/index.html", + "params": { "formId": "159510" } + } + ] + }, + "elements": [] + } + } + ] + } + } + ] + } + """.trimIndent() +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt new file mode 100644 index 00000000..a7ff1f95 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt @@ -0,0 +1,106 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class WebViewReadyCheckerTest { + + /** + * Drives the checker synchronously: `evaluate` answers from a scripted sequence + * (empty -> "false") and scheduled retries run immediately unless held for the + * cancellation test. + */ + private class Harness( + private val answers: MutableList, + private val runScheduledImmediately: Boolean = true + ) { + var evaluateCount = 0 + private set + val scheduledDelays = mutableListOf() + val pendingWork = mutableListOf<() -> Unit>() + + fun makeChecker(): WebViewReadyChecker = WebViewReadyChecker( + evaluate = { _, resultCallback -> + evaluateCount++ + resultCallback(if (answers.isEmpty()) "false" else answers.removeAt(0)) + }, + schedule = { delayMillis, action -> + scheduledDelays.add(delayMillis) + if (runScheduledImmediately) action() else pendingWork.add(action) + } + ) + } + + @Test + fun `an immediately ready page passes on the first attempt`() { + val harness = Harness(mutableListOf("true")) + var readyCalls = 0 + + harness.makeChecker().run( + script = "check", + expectedResult = "true", + onReady = { readyCalls++ }, + onGiveUp = { throw AssertionError("must not give up") } + ) + + assertEquals(1, readyCalls) + assertEquals(1, harness.evaluateCount) + assertTrue(harness.scheduledDelays.isEmpty()) + } + + @Test + fun `a module evaluating after onPageFinished passes on a retry instead of closing the show`() { + val harness = Harness(mutableListOf("false", null, "true")) + var readyCalls = 0 + + harness.makeChecker().run( + script = "check", + expectedResult = "true", + onReady = { readyCalls++ }, + onGiveUp = { throw AssertionError("must not give up") } + ) + + assertEquals(1, readyCalls) + assertEquals(3, harness.evaluateCount) + assertEquals( + listOf(WebViewReadyChecker.RETRY_DELAY_MS, WebViewReadyChecker.RETRY_DELAY_MS), + harness.scheduledDelays + ) + } + + @Test + fun `a page that never boots gives up only after the full retry budget`() { + val harness = Harness(mutableListOf()) + val giveUpReasons = mutableListOf() + + harness.makeChecker().run( + script = "check", + expectedResult = "true", + onReady = { throw AssertionError("must not become ready") }, + onGiveUp = { reason -> giveUpReasons.add(reason) } + ) + + assertEquals(1, giveUpReasons.size) + assertEquals(WebViewReadyChecker.MAX_ATTEMPTS, harness.evaluateCount) + } + + @Test + fun `cancel abandons the poll without ever resolving`() { + val harness = Harness(mutableListOf(), runScheduledImmediately = false) + val checker = harness.makeChecker() + checker.run( + script = "check", + expectedResult = "true", + onReady = { throw AssertionError("must not become ready") }, + onGiveUp = { throw AssertionError("must not give up") } + ) + assertEquals(1, harness.evaluateCount) + + checker.cancel() + harness.pendingWork.forEach { work -> work() } + + // The cancelled checker neither evaluates again nor resolves. + assertEquals(1, harness.evaluateCount) + } +} From 6ad4445059ed0863a5258f360a2465011ffd9620 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 10 Jul 2026 12:28:55 +0300 Subject: [PATCH 2/7] MOBILE-269: Remove profiling. Rename InAppWebViewPrewService. --- .gitignore | 1 + kmp-common-sdk | 2 +- .../main/java/cloud/mindbox/mobile_sdk/Mindbox.kt | 2 +- .../mindbox/mobile_sdk/di/modules/DataModule.kt | 10 +++++----- .../mindbox/mobile_sdk/di/modules/MindboxModule.kt | 2 +- .../repositories/MobileConfigRepositoryImpl.kt | 6 +++--- ...wPrewarmService.kt => InAppWebViewPrewarmer.kt} | 14 +++----------- .../presentation/view/WebViewInappViewHolder.kt | 8 ++++---- .../managers/InAppWebViewLearnedHostsStoreTest.kt | 12 ++++++++++++ .../repositories/MobileConfigRepositoryImplTest.kt | 2 +- ...mplTest.kt => InAppWebViewPrewarmerImplTest.kt} | 6 +++--- 11 files changed, 35 insertions(+), 30 deletions(-) rename sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/{InAppWebViewPrewarmService.kt => InAppWebViewPrewarmer.kt} (97%) rename sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/{InAppWebViewPrewarmServiceImplTest.kt => InAppWebViewPrewarmerImplTest.kt} (98%) diff --git a/.gitignore b/.gitignore index 4ee9d130..3cfad2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -357,3 +357,4 @@ hs_err_pid* # End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,macos,linux,intellij /keys +/example/example.properties diff --git a/kmp-common-sdk b/kmp-common-sdk index 19df8a93..1ab60a84 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 19df8a9382530d74d1d14e52c580ebdc3c08f92f +Subproject commit 1ab60a841b5990ee4cc32b7d25acb369249b6458 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index b163c673..5de8ae0c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -564,7 +564,7 @@ public object Mindbox : MindboxLog { initComponents(context.applicationContext) // Prewarm stage 1: head start for webview in-apps from the cached config // (mirrors the iOS SDK's init-time prewarm; a real show always preempts it). - MindboxDI.appModule.inAppWebViewPrewarmService.prewarmOnInit() + MindboxDI.appModule.inAppWebViewPrewarmer.prewarmOnInit() pushConverters = selectPushServiceHandler(pushServices) logI( "init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 92f5225a..3d87556c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -26,8 +26,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSer import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmServiceImpl +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage @@ -149,8 +149,8 @@ internal fun DataModule( ) } - override val inAppWebViewPrewarmService: InAppWebViewPrewarmService by lazy { - InAppWebViewPrewarmServiceImpl( + override val inAppWebViewPrewarmer: InAppWebViewPrewarmer by lazy { + InAppWebViewPrewarmerImpl( engine = InAppWebViewPrewarmEngine(appContext) { message -> mindboxLogI("[WebView] Prewarm: $message") }, @@ -184,7 +184,7 @@ internal fun DataModule( integerPositiveValidator = integerPositiveValidator, inappSettingsManager = inappSettingsManager, featureToggleManager = featureToggleManager, - inAppWebViewPrewarmService = inAppWebViewPrewarmService + inAppWebViewPrewarmer = inAppWebViewPrewarmer ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index f03e5746..d1ae5495 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -64,7 +64,7 @@ internal interface DataModule : MindboxModule { val sessionStorageManager: SessionStorageManager val mobileConfigRepository: MobileConfigRepository val mobileConfigSerializationManager: MobileConfigSerializationManager - val inAppWebViewPrewarmService: InAppWebViewPrewarmService + val inAppWebViewPrewarmer: InAppWebViewPrewarmer val inAppGeoRepository: InAppGeoRepository val inAppRepository: InAppRepository val callbackRepository: CallbackRepository diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 8c1a13ae..509205ef 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -13,7 +13,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfi 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.InAppTtlData -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI @@ -53,7 +53,7 @@ internal class MobileConfigRepositoryImpl( private val integerPositiveValidator: IntegerPositiveValidator, private val inappSettingsManager: InappSettingsManager, private val featureToggleManager: FeatureToggleManager, - private val inAppWebViewPrewarmService: InAppWebViewPrewarmService + private val inAppWebViewPrewarmer: InAppWebViewPrewarmer ) : MobileConfigRepository { private val mutex = Mutex() @@ -109,7 +109,7 @@ internal class MobileConfigRepositoryImpl( configState.value = updatedInAppConfig // Prewarm stage 2: warm what the config's webview in-apps will need // (or release the warm instance when the config proves there are none). - inAppWebViewPrewarmService.prewarmResources(updatedInAppConfig) + inAppWebViewPrewarmer.prewarmResources(updatedInAppConfig) mindboxLogI(message = "Providing config: $updatedInAppConfig") } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt similarity index 97% rename from sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt rename to sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt index e4ea0e39..41a2788d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt @@ -14,7 +14,6 @@ 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.MindboxWebViewLab import cloud.mindbox.mobile_sdk.inapp.webview.WebViewController import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.logger.mindboxLogW @@ -52,7 +51,7 @@ import java.util.concurrent.atomic.AtomicReference * handed over. Unlike iOS there is no instance reuse — Android shares the renderer * process, so a warm instance buys nothing (measured). */ -internal interface InAppWebViewPrewarmService { +internal interface InAppWebViewPrewarmer { /** Prewarm stage 1: head start from the cached config. Call once at SDK init. */ fun prewarmOnInit() @@ -75,14 +74,14 @@ internal interface InAppWebViewPrewarmService { } @OptIn(InternalMindboxApi::class) -internal class InAppWebViewPrewarmServiceImpl( +internal class InAppWebViewPrewarmerImpl( private val engine: InAppWebViewPrewarmEngine, private val mobileConfigSerializationManager: MobileConfigSerializationManager, private val gatewayManager: Lazy, private val inAppValidator: InAppValidator, private val webViewLayerValidator: WebViewLayerValidator, private val learnedHostsStore: InAppWebViewLearnedHostsStore -) : InAppWebViewPrewarmService { +) : InAppWebViewPrewarmer { companion object { // Hard cap on how long the hidden WebView may live after the content page was @@ -116,7 +115,6 @@ internal class InAppWebViewPrewarmServiceImpl( private val settleJob = AtomicReference(null) override fun prewarmOnInit() { - if (!MindboxWebViewLab.PREWARM_ENABLED) return // MEASUREMENT (throwaway) gate Mindbox.mindboxScope.launch { loggingRunCatchingSuspending { val cachedConfig = MindboxPreferences.inAppConfig @@ -130,7 +128,6 @@ internal class InAppWebViewPrewarmServiceImpl( } override fun prewarmResources(config: InAppConfig) { - if (!MindboxWebViewLab.PREWARM_ENABLED) return // MEASUREMENT (throwaway) gate val layers = config.inApps .flatMap { inApp -> inApp.form.variants } .filterIsInstance() @@ -207,11 +204,6 @@ internal class InAppWebViewPrewarmServiceImpl( mindboxLogI("[WebView] Prewarm: preconnect to ${plan.preconnectOrigins.joinToString(",")} under ${plan.baseUrl}") engine.loadPreconnectPage(plan.preconnectHtml, plan.baseUrl, userAgentSuffix) - if (MindboxWebViewLab.PREWARM_PRECONNECT_ONLY) { // MEASUREMENT (throwaway) gate - scheduleSettleRelease() - return - } - val html = runCatching { gatewayManager.value.fetchWebViewContent(plan.contentUrl) } .getOrElse { error -> mindboxLogW("[WebView] Prewarm: content page fetch failed: $error") 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 5a0e42fb..0e368bbe 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 @@ -24,7 +24,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import androidx.lifecycle.ProcessLifecycleOwner @@ -94,7 +94,7 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val timeProvider: TimeProvider by mindboxInject { timeProvider } - private val webViewPrewarmService: InAppWebViewPrewarmService by mindboxInject { inAppWebViewPrewarmService } + private val webViewPrewarmer: InAppWebViewPrewarmer by mindboxInject { inAppWebViewPrewarmer } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -324,7 +324,7 @@ internal class WebViewInAppViewHolder( private fun handleCloseAction(message: BridgeMessage): String { motionService?.stopMonitoring() // Remember which https hosts this show actually used — feeds the next launch's preconnect. - webViewController?.let { controller -> webViewPrewarmService.captureObservedHosts(controller) } + webViewController?.let { controller -> webViewPrewarmer.captureObservedHosts(controller) } inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -667,7 +667,7 @@ internal class WebViewInAppViewHolder( private fun renderLayer(layer: Layer.WebViewLayer) { if (webViewController == null) { // A real show takes priority: kill the prewarm so it can't compete for bandwidth. - webViewPrewarmService.onRealShowWillStart() + webViewPrewarmer.onRealShowWillStart() val controller: WebViewController = createWebViewController(layer) webViewController = controller diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt index dcf7d82d..3030e87a 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt @@ -46,6 +46,18 @@ internal class InAppWebViewLearnedHostsStoreTest { assertTrue(hosts.contains("old9.ru")) } + @Test + fun `merge drops oldest hosts beyond MAX_HOSTS`() { + store.merge("Endpoint.A", (1..12).map { index -> "old$index.ru" }) + store.merge("Endpoint.A", (1..5).map { index -> "new$index.ru" }) + + val hosts = store.hosts("Endpoint.A") + assertEquals(12, hosts.size) + assertEquals((1..5).map { index -> "new$index.ru" }, hosts.take(5)) + assertEquals((1..7).map { index -> "old$index.ru" }, hosts.drop(5)) + assertTrue((8..12).none { index -> hosts.contains("old$index.ru") }) + } + @Test fun `merge ignores blank input`() { store.merge("Endpoint.A", listOf(" ", "")) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt index df4dcffc..76436edc 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt @@ -99,7 +99,7 @@ internal class MobileConfigRepositoryImplTest { integerPositiveValidator = mockk(relaxed = true), inappSettingsManager = mockk(relaxed = true), featureToggleManager = mockk(relaxed = true), - inAppWebViewPrewarmService = mockk(relaxed = true) + inAppWebViewPrewarmer = mockk(relaxed = true) ) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt similarity index 98% rename from sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt rename to sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt index 0a2cc280..5122d66d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt @@ -38,7 +38,7 @@ import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -internal class InAppWebViewPrewarmServiceImplTest { +internal class InAppWebViewPrewarmerImplTest { private val configuration = Configuration( previousInstallationId = "", @@ -73,7 +73,7 @@ internal class InAppWebViewPrewarmServiceImplTest { private lateinit var engine: InAppWebViewPrewarmEngine private lateinit var gatewayManager: GatewayManager - private lateinit var service: InAppWebViewPrewarmServiceImpl + private lateinit var service: InAppWebViewPrewarmerImpl @Before fun setUp() { @@ -86,7 +86,7 @@ internal class InAppWebViewPrewarmServiceImplTest { engine = mockk(relaxed = true) gatewayManager = mockk(relaxed = true) coEvery { gatewayManager.fetchWebViewContent(any()) } returns "" - service = InAppWebViewPrewarmServiceImpl( + service = InAppWebViewPrewarmerImpl( engine = engine, mobileConfigSerializationManager = MobileConfigSerializationManagerImpl(gson = configGson()), gatewayManager = lazyOf(gatewayManager), From 9f0b6b9e462230c6ccc8bb1df133a4a1102dfe3c Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 10 Jul 2026 18:40:40 +0300 Subject: [PATCH 3/7] MOBILE-269: Add feature toggles for webview cache --- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 2 +- .../mobile_sdk/di/modules/DataModule.kt | 21 +++--- .../mobile_sdk/di/modules/MindboxModule.kt | 2 +- .../data/managers/FeatureToggleManagerImpl.kt | 7 +- .../MobileConfigRepositoryImpl.kt | 6 +- ...armer.kt => InAppWebViewPrewarmManager.kt} | 42 +++++++++-- .../view/WebViewInappViewHolder.kt | 17 +++-- .../MobileConfigRepositoryImplTest.kt | 2 +- ... => InAppWebViewPrewarmManagerImplTest.kt} | 75 +++++++++++++++++-- 9 files changed, 138 insertions(+), 36 deletions(-) rename sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/{InAppWebViewPrewarmer.kt => InAppWebViewPrewarmManager.kt} (91%) rename sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/{InAppWebViewPrewarmerImplTest.kt => InAppWebViewPrewarmManagerImplTest.kt} (83%) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 5de8ae0c..b147c270 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -564,7 +564,7 @@ public object Mindbox : MindboxLog { initComponents(context.applicationContext) // Prewarm stage 1: head start for webview in-apps from the cached config // (mirrors the iOS SDK's init-time prewarm; a real show always preempts it). - MindboxDI.appModule.inAppWebViewPrewarmer.prewarmOnInit() + MindboxDI.appModule.inAppWebViewPrewarmManager.prewarmOnInit() pushConverters = selectPushServiceHandler(pushServices) logI( "init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 3d87556c..734e0aad 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -26,8 +26,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSer import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmerImpl +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage @@ -149,11 +149,13 @@ internal fun DataModule( ) } - override val inAppWebViewPrewarmer: InAppWebViewPrewarmer by lazy { - InAppWebViewPrewarmerImpl( - engine = InAppWebViewPrewarmEngine(appContext) { message -> - mindboxLogI("[WebView] Prewarm: $message") - }, + override val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager by lazy { + InAppWebViewPrewarmManagerImpl( + engine = InAppWebViewPrewarmEngine( + appContext = appContext, + log = { message -> mindboxLogI("[WebView] Prewarm: $message") }, + isCacheEnabled = { featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE) } + ), mobileConfigSerializationManager = mobileConfigSerializationManager, // Lazy on purpose: this service is resolved on the main thread during SDK init // (prewarm stage 1), and constructing GatewayManager spins up Volley (thread @@ -161,7 +163,8 @@ internal fun DataModule( gatewayManager = lazy { gatewayManager }, inAppValidator = inAppValidator, webViewLayerValidator = webViewLayerValidator, - learnedHostsStore = InAppWebViewLearnedHostsStore() + learnedHostsStore = InAppWebViewLearnedHostsStore(), + featureToggleManager = featureToggleManager ) } @@ -184,7 +187,7 @@ internal fun DataModule( integerPositiveValidator = integerPositiveValidator, inappSettingsManager = inappSettingsManager, featureToggleManager = featureToggleManager, - inAppWebViewPrewarmer = inAppWebViewPrewarmer + inAppWebViewPrewarmManager = inAppWebViewPrewarmManager ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index d1ae5495..ddfc9f23 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -64,7 +64,7 @@ internal interface DataModule : MindboxModule { val sessionStorageManager: SessionStorageManager val mobileConfigRepository: MobileConfigRepository val mobileConfigSerializationManager: MobileConfigSerializationManager - val inAppWebViewPrewarmer: InAppWebViewPrewarmer + val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager val inAppGeoRepository: InAppGeoRepository val inAppRepository: InAppRepository val callbackRepository: CallbackRepository diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt index c580c6ad..2dc1a801 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt @@ -6,6 +6,11 @@ import java.util.concurrent.ConcurrentHashMap internal const val SEND_INAPP_SHOW_ERROR_FEATURE = "MobileSdkShouldSendInAppShowError" internal const val SEND_INAPP_TAGS_FEATURE = "MobileSdkShouldSendInAppTags" +internal const val PREWARM_INAPP_WEBVIEW_FEATURE = "MobileSdkShouldPrewarmInAppWebView" +internal const val CACHE_INAPP_WEBVIEW_FEATURE = "MobileSdkShouldCacheInAppWebView" + +/** Every toggle is a kill switch: an absent/unknown key defaults to enabled. */ +internal const val FEATURE_TOGGLE_DEFAULT: Boolean = true internal class FeatureToggleManagerImpl : FeatureToggleManager { @@ -21,6 +26,6 @@ internal class FeatureToggleManagerImpl : FeatureToggleManager { } override fun isEnabled(key: String): Boolean { - return toggles[key] ?: true + return toggles[key] ?: FEATURE_TOGGLE_DEFAULT } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 509205ef..770335e6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -13,7 +13,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfi 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.InAppTtlData -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI @@ -53,7 +53,7 @@ internal class MobileConfigRepositoryImpl( private val integerPositiveValidator: IntegerPositiveValidator, private val inappSettingsManager: InappSettingsManager, private val featureToggleManager: FeatureToggleManager, - private val inAppWebViewPrewarmer: InAppWebViewPrewarmer + private val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager ) : MobileConfigRepository { private val mutex = Mutex() @@ -109,7 +109,7 @@ internal class MobileConfigRepositoryImpl( configState.value = updatedInAppConfig // Prewarm stage 2: warm what the config's webview in-apps will need // (or release the warm instance when the config proves there are none). - inAppWebViewPrewarmer.prewarmResources(updatedInAppConfig) + inAppWebViewPrewarmManager.prewarmResources(updatedInAppConfig) mindboxLogI(message = "Providing config: $updatedInAppConfig") } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt similarity index 91% rename from sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt rename to sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt index 41a2788d..df7a2eda 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt @@ -4,8 +4,11 @@ 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 @@ -21,6 +24,7 @@ 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 @@ -51,7 +55,7 @@ import java.util.concurrent.atomic.AtomicReference * handed over. Unlike iOS there is no instance reuse — Android shares the renderer * process, so a warm instance buys nothing (measured). */ -internal interface InAppWebViewPrewarmer { +internal interface InAppWebViewPrewarmManager { /** Prewarm stage 1: head start from the cached config. Call once at SDK init. */ fun prewarmOnInit() @@ -74,14 +78,15 @@ internal interface InAppWebViewPrewarmer { } @OptIn(InternalMindboxApi::class) -internal class InAppWebViewPrewarmerImpl( +internal class InAppWebViewPrewarmManagerImpl( private val engine: InAppWebViewPrewarmEngine, private val mobileConfigSerializationManager: MobileConfigSerializationManager, private val gatewayManager: Lazy, private val inAppValidator: InAppValidator, private val webViewLayerValidator: WebViewLayerValidator, - private val learnedHostsStore: InAppWebViewLearnedHostsStore -) : InAppWebViewPrewarmer { + private val learnedHostsStore: InAppWebViewLearnedHostsStore, + private val featureToggleManager: FeatureToggleManager +) : InAppWebViewPrewarmManager { companion object { // Hard cap on how long the hidden WebView may live after the content page was @@ -128,6 +133,13 @@ internal class InAppWebViewPrewarmerImpl( } 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() @@ -136,9 +148,7 @@ internal class InAppWebViewPrewarmerImpl( .map { layer -> InAppWebViewPrewarmLayer(baseUrl = layer.baseUrl, contentUrl = layer.contentUrl) } if (layers.isEmpty()) { mindboxLogI("[WebView] Prewarm: config has no webview in-apps, releasing warm WebView") - latestConfigHasNoLayers.set(true) - settleJob.getAndSet(null)?.cancel() - engine.release() + releaseWarmWebView() return } latestConfigHasNoLayers.set(false) @@ -149,6 +159,12 @@ internal class InAppWebViewPrewarmerImpl( } } + private fun releaseWarmWebView() { + latestConfigHasNoLayers.set(true) + settleJob.getAndSet(null)?.cancel() + engine.release() + } + override fun onRealShowWillStart() { hasAborted.set(true) settleJob.getAndSet(null)?.cancel() @@ -311,10 +327,17 @@ internal class InAppWebViewPrewarmerImpl( private suspend fun currentConfiguration(): Configuration? = runCatching { DbManager.listenConfigurations().first() }.getOrNull() - /** Light parse of the cached config: only webview layer urls, no targeting checks. */ + /** + * 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 { val configBlank = mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) ?: return emptyList() + if (!isPrewarmEnabled(configBlank)) 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. @@ -330,6 +353,9 @@ internal class InAppWebViewPrewarmerImpl( .map { layerDto -> InAppWebViewPrewarmLayer(baseUrl = layerDto.baseUrl, contentUrl = layerDto.contentUrl) } } + 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. 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 0e368bbe..194b8f34 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 @@ -14,17 +14,19 @@ import cloud.mindbox.mobile_sdk.* import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.CACHE_INAPP_WEBVIEW_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator import cloud.mindbox.mobile_sdk.inapp.data.validators.HapticRequestValidator import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import androidx.lifecycle.ProcessLifecycleOwner @@ -94,7 +96,8 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val timeProvider: TimeProvider by mindboxInject { timeProvider } - private val webViewPrewarmer: InAppWebViewPrewarmer by mindboxInject { inAppWebViewPrewarmer } + private val webViewPrewarmManager: InAppWebViewPrewarmManager by mindboxInject { inAppWebViewPrewarmManager } + private val featureToggleManager: FeatureToggleManager by mindboxInject { featureToggleManager } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -324,7 +327,7 @@ internal class WebViewInAppViewHolder( private fun handleCloseAction(message: BridgeMessage): String { motionService?.stopMonitoring() // Remember which https hosts this show actually used — feeds the next launch's preconnect. - webViewController?.let { controller -> webViewPrewarmer.captureObservedHosts(controller) } + webViewController?.let { controller -> webViewPrewarmManager.captureObservedHosts(controller) } inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -424,7 +427,11 @@ internal class WebViewInAppViewHolder( private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController { mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}") - val controller: WebViewController = WebViewController.create(currentDialog.context, BuildConfig.DEBUG) + val controller: WebViewController = WebViewController.create( + context = currentDialog.context, + isDebugEnabled = BuildConfig.DEBUG, + isCacheEnabled = featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE) + ) val view: WebViewPlatformView = controller.view view.layoutParams = RelativeLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -667,7 +674,7 @@ internal class WebViewInAppViewHolder( private fun renderLayer(layer: Layer.WebViewLayer) { if (webViewController == null) { // A real show takes priority: kill the prewarm so it can't compete for bandwidth. - webViewPrewarmer.onRealShowWillStart() + webViewPrewarmManager.onRealShowWillStart() val controller: WebViewController = createWebViewController(layer) webViewController = controller diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt index 76436edc..d378d8f7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt @@ -99,7 +99,7 @@ internal class MobileConfigRepositoryImplTest { integerPositiveValidator = mockk(relaxed = true), inappSettingsManager = mockk(relaxed = true), featureToggleManager = mockk(relaxed = true), - inAppWebViewPrewarmer = mockk(relaxed = true) + inAppWebViewPrewarmManager = mockk(relaxed = true) ) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt similarity index 83% rename from sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt rename to sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt index 5122d66d..5ff07197 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt @@ -5,7 +5,9 @@ import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadBlankDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl +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.models.Form import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType @@ -38,7 +40,7 @@ import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -internal class InAppWebViewPrewarmerImplTest { +internal class InAppWebViewPrewarmManagerImplTest { private val configuration = Configuration( previousInstallationId = "", @@ -73,7 +75,8 @@ internal class InAppWebViewPrewarmerImplTest { private lateinit var engine: InAppWebViewPrewarmEngine private lateinit var gatewayManager: GatewayManager - private lateinit var service: InAppWebViewPrewarmerImpl + private lateinit var featureToggleManager: FeatureToggleManager + private lateinit var service: InAppWebViewPrewarmManagerImpl @Before fun setUp() { @@ -86,7 +89,10 @@ internal class InAppWebViewPrewarmerImplTest { engine = mockk(relaxed = true) gatewayManager = mockk(relaxed = true) coEvery { gatewayManager.fetchWebViewContent(any()) } returns "" - service = InAppWebViewPrewarmerImpl( + featureToggleManager = mockk(relaxed = true) { + every { isEnabled(any()) } returns true + } + service = InAppWebViewPrewarmManagerImpl( engine = engine, mobileConfigSerializationManager = MobileConfigSerializationManagerImpl(gson = configGson()), gatewayManager = lazyOf(gatewayManager), @@ -96,7 +102,8 @@ internal class InAppWebViewPrewarmerImplTest { webViewLayerValidator = WebViewLayerValidator(), learnedHostsStore = mockk(relaxed = true) { every { hosts(any()) } returns listOf("learned-cdn.mindbox.ru") - } + }, + featureToggleManager = featureToggleManager ) } @@ -282,6 +289,36 @@ internal class InAppWebViewPrewarmerImplTest { verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } } + @Test + fun `prewarmResources releases warm webview and skips warming when the feature toggle is off`() { + every { featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE) } returns false + + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { engine.release() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + coVerify(exactly = 0) { gatewayManager.fetchWebViewContent(any()) } + } + + @Test + fun `prewarmResources warms normally once the feature toggle turns back on`() { + every { featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE) } returns false + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + + every { featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE) } returns true + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + @Test fun `real show preempts prewarm terminally and blocks later attempts`() { service.onRealShowWillStart() @@ -317,6 +354,25 @@ internal class InAppWebViewPrewarmerImplTest { verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } } + @Test + fun `prewarmOnInit does nothing when the cached config disables the feature toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = false) + + service.prewarmOnInit() + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `prewarmOnInit warms when the cached config explicitly enables the feature toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = true) + + service.prewarmOnInit() + + verify(exactly = 1) { engine.loadPreconnectPage(any(), "https://inapp.local/popup", any()) } + } + /** Same `${'$'}type` adapters the production gson registers for the form payload path. */ private fun configGson(): Gson = GsonBuilder() .registerTypeAdapterFactory( @@ -339,7 +395,11 @@ internal class InAppWebViewPrewarmerImplTest { ) .create() - private fun cachedConfigJson(): String = """ + private fun cachedConfigJson(prewarmToggle: Boolean? = null): String { + val settings = prewarmToggle?.let { + ", \"settings\": { \"featureToggles\": { \"MobileSdkShouldPrewarmInAppWebView\": $it } }" + }.orEmpty() + return """ { "inapps": [ { @@ -367,7 +427,8 @@ internal class InAppWebViewPrewarmerImplTest { ] } } - ] + ]$settings } - """.trimIndent() + """.trimIndent() + } } From 1e48ba434817a08e8802d6e0e4dacb8ef923beb0 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 10 Jul 2026 19:10:26 +0300 Subject: [PATCH 4/7] MOBILE-269: Add tests --- kmp-common-sdk | 2 +- .../inapp/presentation/InAppWebViewPrewarmManager.kt | 5 ++++- .../inapp/presentation/view/WebViewInappViewHolder.kt | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/kmp-common-sdk b/kmp-common-sdk index 1ab60a84..e4fe2629 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 1ab60a841b5990ee4cc32b7d25acb369249b6458 +Subproject commit e4fe2629533ed913e4e47d53cd51db5ada7be907 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 df7a2eda..85ead834 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 @@ -337,7 +337,10 @@ internal class InAppWebViewPrewarmManagerImpl( private fun webViewLayers(configString: String): List { val configBlank = mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) ?: return emptyList() - if (!isPrewarmEnabled(configBlank)) return emptyList() + 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. 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 194b8f34..008551a9 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 @@ -430,7 +430,8 @@ internal class WebViewInAppViewHolder( val controller: WebViewController = WebViewController.create( context = currentDialog.context, isDebugEnabled = BuildConfig.DEBUG, - isCacheEnabled = featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE) + isCacheEnabled = featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE), + log = { message -> mindboxLogI("[WebView] $message") } ) val view: WebViewPlatformView = controller.view view.layoutParams = RelativeLayout.LayoutParams( From 52a982ed32c5ab895b0f5559976e3b91f932cd72 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 14 Jul 2026 10:43:18 +0300 Subject: [PATCH 5/7] MOBILE-269: Add InAppWebViewCachePolicy --- .gitmodules | 2 +- kmp-common-sdk | 2 +- .../mobile_sdk/di/modules/DataModule.kt | 10 +- .../mobile_sdk/di/modules/MindboxModule.kt | 1 + .../presentation/InAppWebViewCachePolicy.kt | 55 ++++++++ .../InAppWebViewPrewarmManager.kt | 14 +- .../view/WebViewInappViewHolder.kt | 7 +- .../InAppWebViewCachePolicyTest.kt | 125 ++++++++++++++++++ .../InAppWebViewPrewarmManagerImplTest.kt | 45 ++++++- 9 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt diff --git a/.gitmodules b/.gitmodules index db7f9ca9..aa354a45 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "kmp-common-sdk"] path = kmp-common-sdk url = https://github.com/mindbox-cloud/kmp-common-sdk.git - branch = prototype/webview-cache-profiling + branch = develop \ No newline at end of file diff --git a/kmp-common-sdk b/kmp-common-sdk index e4fe2629..bddef579 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit e4fe2629533ed913e4e47d53cd51db5ada7be907 +Subproject commit bddef5793ab0d1c3741e7702afbe89870f98f059 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 734e0aad..4f3ec535 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -26,6 +26,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSer import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewCachePolicy import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager @@ -149,12 +150,16 @@ internal fun DataModule( ) } + override val webViewCachePolicy: InAppWebViewCachePolicy by lazy { + InAppWebViewCachePolicy(mobileConfigSerializationManager) + } + override val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager by lazy { InAppWebViewPrewarmManagerImpl( engine = InAppWebViewPrewarmEngine( appContext = appContext, log = { message -> mindboxLogI("[WebView] Prewarm: $message") }, - isCacheEnabled = { featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE) } + isCacheEnabled = { webViewCachePolicy.isCacheEnabled } ), mobileConfigSerializationManager = mobileConfigSerializationManager, // Lazy on purpose: this service is resolved on the main thread during SDK init @@ -164,7 +169,8 @@ internal fun DataModule( inAppValidator = inAppValidator, webViewLayerValidator = webViewLayerValidator, learnedHostsStore = InAppWebViewLearnedHostsStore(), - featureToggleManager = featureToggleManager + featureToggleManager = featureToggleManager, + webViewCachePolicy = webViewCachePolicy ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index ddfc9f23..696a863d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -65,6 +65,7 @@ internal interface DataModule : MindboxModule { val mobileConfigRepository: MobileConfigRepository val mobileConfigSerializationManager: MobileConfigSerializationManager val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager + val webViewCachePolicy: InAppWebViewCachePolicy val inAppGeoRepository: InAppGeoRepository val inAppRepository: InAppRepository val callbackRepository: CallbackRepository diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt new file mode 100644 index 00000000..c543471a --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt @@ -0,0 +1,55 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.inapp.data.managers.CACHE_INAPP_WEBVIEW_FEATURE +import cloud.mindbox.mobile_sdk.inapp.data.managers.FEATURE_TOGGLE_DEFAULT +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponseBlank +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences + +/** + * The cache half of the WebView feature toggles (`MobileSdkShouldCacheInAppWebView`), + * latched once per process from the cached config on disk — mirrors iOS's + * `InAppWebViewDataStore.isCacheFeatureEnabled`. + * + * Read from the cache, not [cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager]: + * the prewarm's first WebView is created at SDK init, before any fresh config can populate + * the manager — reading the live toggle there always sees the default (enabled), even when + * the cached config already says otherwise. The decision is latched for the whole launch by + * design: prewarm and every later show must agree, or the cache would be split between two + * behaviors for the same session. This is also why the value can't just live in + * [cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager]'s toggle + * map: that map is cleared and repopulated from the fresh config on every fetch, which would + * silently un-latch this decision the first time a fresh config disagreed with the cache. + */ +internal class InAppWebViewCachePolicy( + private val mobileConfigSerializationManager: MobileConfigSerializationManager +) { + + @Volatile + private var latched: Boolean? = null + + val isCacheEnabled: Boolean + @Synchronized get() = latched ?: extract(parseCachedConfigBlank()).also { latched = it } + + /** + * Lets a caller that already parsed the cached config blank for its own purposes (the + * prewarm manager, which needs the same blank for its layers and prewarm-toggle checks) + * hand it over instead of this class deserializing the same JSON a second time. + * + * First value wins: a call after the toggle has already latched — from here or from + * [isCacheEnabled] itself — is a no-op. + */ + @Synchronized + fun prime(configBlank: InAppConfigResponseBlank?) { + if (latched == null) latched = extract(configBlank) + } + + private fun extract(configBlank: InAppConfigResponseBlank?): Boolean = + configBlank?.settings?.featureToggles?.toggles?.get(CACHE_INAPP_WEBVIEW_FEATURE) ?: FEATURE_TOGGLE_DEFAULT + + private fun parseCachedConfigBlank(): InAppConfigResponseBlank? { + val configString = MindboxPreferences.inAppConfig + if (configString.isBlank()) return null + return mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) + } +} 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 85ead834..52b9f770 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 @@ -28,6 +28,7 @@ import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponseBla 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 @@ -38,6 +39,7 @@ 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 @@ -74,6 +76,7 @@ internal interface InAppWebViewPrewarmManager { fun onRealShowWillStart() /** Records the https hosts the shown page actually used (learned-hosts store). */ + @OptIn(InternalMindboxApi::class) fun captureObservedHosts(controller: WebViewController) } @@ -85,7 +88,8 @@ internal class InAppWebViewPrewarmManagerImpl( private val inAppValidator: InAppValidator, private val webViewLayerValidator: WebViewLayerValidator, private val learnedHostsStore: InAppWebViewLearnedHostsStore, - private val featureToggleManager: FeatureToggleManager + private val featureToggleManager: FeatureToggleManager, + private val webViewCachePolicy: InAppWebViewCachePolicy ) : InAppWebViewPrewarmManager { companion object { @@ -271,7 +275,7 @@ internal class InAppWebViewPrewarmManagerImpl( var stablePolls = 0 var idle = false while (polls < budgetPolls) { - delay(IDLE_POLL_MS) + delay(IDLE_POLL_MS.milliseconds) polls++ if (hasAborted.get()) return@launch val probe = probeResourceState() @@ -306,8 +310,9 @@ internal class InAppWebViewPrewarmManagerImpl( * 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) { + withTimeoutOrNull(IDLE_QUIET_MS.milliseconds) { suspendCancellableCoroutine { continuation -> engine.evaluateJavaScript(IDLE_PROBE_JS) { rawResult -> // evaluateJavascript JSON-quotes string results: "\"6:3456\"". @@ -337,6 +342,9 @@ internal class InAppWebViewPrewarmManagerImpl( private fun webViewLayers(configString: String): List { 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() 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 008551a9..191d6909 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 @@ -14,18 +14,17 @@ import cloud.mindbox.mobile_sdk.* import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto -import cloud.mindbox.mobile_sdk.inapp.data.managers.CACHE_INAPP_WEBVIEW_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator import cloud.mindbox.mobile_sdk.inapp.data.validators.HapticRequestValidator import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager -import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewCachePolicy import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView @@ -97,7 +96,7 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val timeProvider: TimeProvider by mindboxInject { timeProvider } private val webViewPrewarmManager: InAppWebViewPrewarmManager by mindboxInject { inAppWebViewPrewarmManager } - private val featureToggleManager: FeatureToggleManager by mindboxInject { featureToggleManager } + private val webViewCachePolicy: InAppWebViewCachePolicy by mindboxInject { webViewCachePolicy } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -430,7 +429,7 @@ internal class WebViewInAppViewHolder( val controller: WebViewController = WebViewController.create( context = currentDialog.context, isDebugEnabled = BuildConfig.DEBUG, - isCacheEnabled = featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE), + isCacheEnabled = webViewCachePolicy.isCacheEnabled, log = { message -> mindboxLogI("[WebView] $message") } ) val view: WebViewPlatformView = controller.view diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt new file mode 100644 index 00000000..88befd3f --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt @@ -0,0 +1,125 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import com.google.gson.Gson +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +internal class InAppWebViewCachePolicyTest { + + private val serializationManager = MobileConfigSerializationManagerImpl(gson = Gson()) + + @Before + fun setUp() { + mockkObject(MindboxPreferences) + } + + @After + fun tearDown() { + unmockkObject(MindboxPreferences) + } + + @Test + fun `isCacheEnabled is false when the cached config disables the toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = false) + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(false, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled is true when the cached config explicitly enables the toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = true) + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled defaults to true when the cached config has no toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = null) + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled defaults to true when there is no cached config`() { + every { MindboxPreferences.inAppConfig } returns "" + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled is latched for the process and ignores a later cached config change`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = false) + val policy = InAppWebViewCachePolicy(serializationManager) + assertEquals(false, policy.isCacheEnabled) + + // A fresh config lands and flips the cached toggle mid-session — the already + // latched decision must not change, or the WebView cache would be split between + // two behaviors within the same launch. + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = true) + + assertEquals(false, policy.isCacheEnabled) + } + + @Test + fun `prime latches the toggle from an already-parsed config blank without touching the cached config`() { + val configBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = false)) + val policy = InAppWebViewCachePolicy(serializationManager) + + // MindboxPreferences.inAppConfig is deliberately left unstubbed: if isCacheEnabled + // fell back to parsing the cache itself instead of using the primed value, this + // would throw on the unstubbed mock. + policy.prime(configBlank) + + assertEquals(false, policy.isCacheEnabled) + } + + @Test + fun `prime is a no-op once isCacheEnabled has already latched a value`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = true) + val policy = InAppWebViewCachePolicy(serializationManager) + assertEquals(true, policy.isCacheEnabled) + + val disabledBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = false)) + policy.prime(disabledBlank) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `prime keeps the first primed value on a later prime call`() { + val policy = InAppWebViewCachePolicy(serializationManager) + val enabledBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = true)) + val disabledBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = false)) + + policy.prime(enabledBlank) + policy.prime(disabledBlank) + + assertEquals(true, policy.isCacheEnabled) + } + + private fun cachedConfigJson(cacheToggle: Boolean?): String { + val toggle = cacheToggle?.let { "\"MobileSdkShouldCacheInAppWebView\": $it" }.orEmpty() + return """ + { + "settings": { + "featureToggles": { $toggle } + } + } + """.trimIndent() + } +} 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 5ff07197..0437c041 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 @@ -8,6 +8,7 @@ import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationMan 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.models.Form import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType @@ -27,6 +28,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.spyk import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.CoroutineScope @@ -36,6 +38,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -76,6 +79,8 @@ internal class InAppWebViewPrewarmManagerImplTest { private lateinit var engine: InAppWebViewPrewarmEngine private lateinit var gatewayManager: GatewayManager private lateinit var featureToggleManager: FeatureToggleManager + private lateinit var mobileConfigSerializationManager: MobileConfigSerializationManager + private lateinit var webViewCachePolicy: InAppWebViewCachePolicy private lateinit var service: InAppWebViewPrewarmManagerImpl @Before @@ -92,9 +97,13 @@ internal class InAppWebViewPrewarmManagerImplTest { featureToggleManager = mockk(relaxed = true) { every { isEnabled(any()) } returns true } + // spyk, not a plain instance: lets tests verify the cached config blank is + // deserialized only once and shared with the cache policy, not parsed twice. + mobileConfigSerializationManager = spyk(MobileConfigSerializationManagerImpl(gson = configGson())) + webViewCachePolicy = InAppWebViewCachePolicy(mobileConfigSerializationManager) service = InAppWebViewPrewarmManagerImpl( engine = engine, - mobileConfigSerializationManager = MobileConfigSerializationManagerImpl(gson = configGson()), + mobileConfigSerializationManager = mobileConfigSerializationManager, gatewayManager = lazyOf(gatewayManager), inAppValidator = mockk(relaxed = true) { every { validateInAppVersion(any()) } returns true @@ -103,7 +112,8 @@ internal class InAppWebViewPrewarmManagerImplTest { learnedHostsStore = mockk(relaxed = true) { every { hosts(any()) } returns listOf("learned-cdn.mindbox.ru") }, - featureToggleManager = featureToggleManager + featureToggleManager = featureToggleManager, + webViewCachePolicy = webViewCachePolicy ) } @@ -373,6 +383,25 @@ internal class InAppWebViewPrewarmManagerImplTest { verify(exactly = 1) { engine.loadPreconnectPage(any(), "https://inapp.local/popup", any()) } } + @Test + fun `prewarmOnInit primes the cache toggle from its own parse instead of a second deserialize`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = true, cacheToggle = false) + + service.prewarmOnInit() + + assertEquals(false, webViewCachePolicy.isCacheEnabled) + verify(exactly = 1) { mobileConfigSerializationManager.deserializeToConfigDtoBlank(any()) } + } + + @Test + fun `prewarmOnInit primes the cache toggle even when the prewarm toggle is off`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = false, cacheToggle = false) + + service.prewarmOnInit() + + assertEquals(false, webViewCachePolicy.isCacheEnabled) + } + /** Same `${'$'}type` adapters the production gson registers for the form payload path. */ private fun configGson(): Gson = GsonBuilder() .registerTypeAdapterFactory( @@ -395,10 +424,14 @@ internal class InAppWebViewPrewarmManagerImplTest { ) .create() - private fun cachedConfigJson(prewarmToggle: Boolean? = null): String { - val settings = prewarmToggle?.let { - ", \"settings\": { \"featureToggles\": { \"MobileSdkShouldPrewarmInAppWebView\": $it } }" - }.orEmpty() + private fun cachedConfigJson(prewarmToggle: Boolean? = null, cacheToggle: Boolean? = null): String { + val toggles = listOfNotNull( + prewarmToggle?.let { "\"MobileSdkShouldPrewarmInAppWebView\": $it" }, + cacheToggle?.let { "\"MobileSdkShouldCacheInAppWebView\": $it" } + ) + val settings = toggles.takeIf { it.isNotEmpty() } + ?.let { ", \"settings\": { \"featureToggles\": { ${it.joinToString(", ")} } }" } + .orEmpty() return """ { "inapps": [ From 7c31a188290dccafbc0e2c9a5e14d2d3d491b5ba Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 14 Jul 2026 13:54:33 +0300 Subject: [PATCH 6/7] MOBILE-269: Follow code review --- .gitmodules | 2 +- kmp-common-sdk | 2 +- .../presentation/InAppWebViewPrewarmManager.kt | 7 +++++++ .../presentation/view/WebViewInappViewHolder.kt | 13 ++++++------- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.gitmodules b/.gitmodules index aa354a45..e60d3b3d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "kmp-common-sdk"] path = kmp-common-sdk url = https://github.com/mindbox-cloud/kmp-common-sdk.git - branch = develop \ No newline at end of file + branch = develop \ No newline at end of file diff --git a/kmp-common-sdk b/kmp-common-sdk index bddef579..6cfaa316 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit bddef5793ab0d1c3741e7702afbe89870f98f059 +Subproject commit 6cfaa316f450a08fa936bfac681af2cbe75bff52 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 52b9f770..e55345a1 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 @@ -262,6 +262,10 @@ internal class InAppWebViewPrewarmManagerImpl( // 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 @@ -301,6 +305,9 @@ internal class InAppWebViewPrewarmManagerImpl( engine.release() } settleJob.getAndSet(job)?.cancel() + if (hasAborted.get()) { + settleJob.getAndSet(null)?.cancel() + } } private data class ResourceProbe(val entryCount: Int, val msSinceLastResponseEnd: Long) 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 191d6909..c84b6e35 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 @@ -565,7 +565,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "JS ready check gave up for $url: $lastFailure" + errorDescription = "JS ready check gave up for $url: $lastFailure", + tags = wrapper.tags ) } ) @@ -588,9 +589,9 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "evaluateJavaScript return unexpected response: $response", - tags = wrapper.tags - ) + errorDescription = "evaluateJavaScript returned unexpected response: $response", + tags = wrapper.tags + ) } else { mindboxLogW("evaluateJavaScript miss after teardown (ignored): $response") } @@ -761,9 +762,7 @@ internal class WebViewInAppViewHolder( private fun onContentPageLoaded(content: WebViewHtmlContent) { webViewController?.let { controller -> - controller.executeOnViewThread { - controller.loadContent(content) - } + controller.loadContent(content) startTimer { inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, From 654fb670acec14c2ad35aab4292ea57cac6cb977 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 14 Jul 2026 17:21:59 +0300 Subject: [PATCH 7/7] MOBILE-269: Follow code review --- .gitmodules | 2 +- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 1 + .../InAppWebViewPrewarmManager.kt | 50 +++++-- .../view/WebViewInappViewHolder.kt | 54 ++++++-- .../InAppWebViewPrewarmManagerImplTest.kt | 125 +++++++++++++++++- 5 files changed, 208 insertions(+), 24 deletions(-) diff --git a/.gitmodules b/.gitmodules index e60d3b3d..972e0874 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "kmp-common-sdk"] path = kmp-common-sdk url = https://github.com/mindbox-cloud/kmp-common-sdk.git - branch = develop \ No newline at end of file + branch = develop \ No newline at end of file diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index b147c270..6662d32d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -1380,6 +1380,7 @@ public object Mindbox : MindboxLog { private fun softReinitialization( context: Context, ) { + MindboxDI.appModule.inAppWebViewPrewarmManager.terminate() mindboxScope.cancel() DbManager.removeAllEventsFromQueue() BackgroundWorkManager.cancelAllWork(context) 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 e55345a1..e6ad0036 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 @@ -1,5 +1,6 @@ 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 @@ -78,6 +79,14 @@ internal interface InAppWebViewPrewarmManager { /** 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) @@ -124,10 +133,24 @@ internal class InAppWebViewPrewarmManagerImpl( private val settleJob = AtomicReference(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()) return@loggingRunCatchingSuspending + 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))") @@ -169,11 +192,15 @@ internal class InAppWebViewPrewarmManagerImpl( engine.release() } - override fun onRealShowWillStart() { + 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 mid-show. + // a background thread cannot resurrect the WebView afterward. engine.abort() } @@ -214,11 +241,12 @@ internal class InAppWebViewPrewarmManagerImpl( mindboxLogW("[WebView] Prewarm: no valid webview layer urls in config, skipping") return } - if (!hasStartedResourcePrewarm.compareAndSet(false, true)) return - // Re-check after the configuration read suspension: a fresh no-layers config that - // landed while this (init-triggered) prewarm was suspended must keep the WebView - // from ever being created — release() posted at that point was a no-op. + // 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 val userAgentSuffix = configuration.getShortUserAgent() mindboxLogI("[WebView] Prewarm: preconnect to ${plan.preconnectOrigins.joinToString(",")} under ${plan.baseUrl}") @@ -359,16 +387,22 @@ internal class InAppWebViewPrewarmManagerImpl( 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() - .flatMap { modal -> modal.content?.background?.layers.orEmpty() } + // 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() .filter { layerDto -> webViewLayerValidator.isValid(layerDto) } .map { layerDto -> InAppWebViewPrewarmLayer(baseUrl = layerDto.baseUrl, contentUrl = layerDto.contentUrl) } + .toList() } private fun isPrewarmEnabled(configBlank: InAppConfigResponseBlank): Boolean = 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 c84b6e35..be2d52ce 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 @@ -84,6 +84,9 @@ internal class WebViewInAppViewHolder( private var readyChecker: WebViewReadyChecker? = null private val mainHandler: Handler = Handler(Looper.getMainLooper()) + private var hasInitialized = false + private var pendingReadyCheckFailure: String? = null + private var motionService: MotionServiceProtocol? = null private fun bindWebViewBackAction(currentRoot: MindboxView, controller: WebViewController) { @@ -286,6 +289,7 @@ internal class WebViewInAppViewHolder( } private fun handleInitAction(controller: WebViewController): String { + hasInitialized = true stopTimer() wrapper.inAppActionCallbacks.onInAppShown.onShown() val mindboxView = currentMindboxView ?: run { @@ -325,8 +329,6 @@ internal class WebViewInAppViewHolder( private fun handleCloseAction(message: BridgeMessage): String { motionService?.stopMonitoring() - // Remember which https hosts this show actually used — feeds the next launch's preconnect. - webViewController?.let { controller -> webViewPrewarmManager.captureObservedHosts(controller) } inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -538,10 +540,13 @@ internal class WebViewInAppViewHolder( * [sendActionInternal] intentionally stays single-shot ([checkEvaluateJavaScript]) — that * path talks to a page that already proved itself ready. * - * Give-up records the failure but does NOT close: the init timer is the closing - * authority. The checker's ~1.2s budget can expire while an allowed same-origin - * navigation is still in flight or while a slow page is still booting its bridge — - * cases the timer would have accepted (the window stays invisible until `init` anyway). + * Give-up BEFORE `init` only records the failure ([pendingReadyCheckFailure]) — the + * init timer is the closing authority there, since the checker's ~1.2s budget can + * expire while an allowed same-origin navigation is still in flight or a slow page is + * still booting its bridge (cases the timer would have accepted; the window stays + * invisible until `init` anyway). Give-up AFTER `init` closes immediately instead: a + * same-origin navigation broke a bridge that had already proven itself alive, and + * there is no init timer left to catch it. */ private fun startReadyCheck(url: String?) { // A late onPageFinished can be queued on the main looper when the in-app closes; @@ -562,12 +567,17 @@ internal class WebViewInAppViewHolder( expectedResult = JS_RETURN, onReady = { mindboxLogD("JS ready check passed for $url") }, onGiveUp = { lastFailure -> - inAppFailureTracker.sendFailureWithContext( - inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "JS ready check gave up for $url: $lastFailure", - tags = wrapper.tags - ) + if (hasInitialized) { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "JS ready check gave up after init for $url: $lastFailure", + tags = wrapper.tags + ) + inAppController.close() + } else { + pendingReadyCheckFailure = lastFailure + } } ) } @@ -762,12 +772,26 @@ internal class WebViewInAppViewHolder( private fun onContentPageLoaded(content: WebViewHtmlContent) { webViewController?.let { controller -> + hasInitialized = false + pendingReadyCheckFailure = null 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 inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.WEBVIEW_LOAD_FAILED, - errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}.", + failureReason = if (readyCheckFailure != null) { + FailureReason.WEBVIEW_PRESENTATION_FAILED + } else { + FailureReason.WEBVIEW_LOAD_FAILED + }, + errorDescription = if (readyCheckFailure != null) { + "JS ready check never passed before init timeout: $readyCheckFailure" + } else { + "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." + }, tags = wrapper.tags ) controller.executeOnViewThread { @@ -836,6 +860,8 @@ internal class WebViewInAppViewHolder( readyChecker = null cancelPendingResponses("WebView In-App is closed") webViewController?.let { controller -> + // Remember which https hosts this show actually used — feeds the next launch's preconnect. + webViewPrewarmManager.captureObservedHosts(controller) // Detach first: a page event already queued on the main looper must not reach // this torn-down holder (e.g. a late onPageFinished spawning a ready checker). controller.setEventListener(null) 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 0437c041..c7faa4e3 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 @@ -1,6 +1,8 @@ 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.PayloadBlankDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto @@ -42,7 +44,7 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, InternalMindboxApi::class) internal class InAppWebViewPrewarmManagerImplTest { private val configuration = Configuration( @@ -91,6 +93,8 @@ internal class InAppWebViewPrewarmManagerImplTest { every { DbManager.listenConfigurations() } returns flowOf(configuration) mockkObject(MindboxPreferences) every { MindboxPreferences.deviceUuid } returns "test-device-uuid" + mockkObject(InitializeLock) + coEvery { InitializeLock.await(any()) } returns Unit engine = mockk(relaxed = true) gatewayManager = mockk(relaxed = true) coEvery { gatewayManager.fetchWebViewContent(any()) } returns "" @@ -122,6 +126,7 @@ internal class InAppWebViewPrewarmManagerImplTest { unmockkObject(Mindbox) unmockkObject(DbManager) unmockkObject(MindboxPreferences) + unmockkObject(InitializeLock) } private fun configWith(vararg inApps: cloud.mindbox.mobile_sdk.inapp.domain.models.InApp) = InAppConfig( @@ -290,6 +295,32 @@ internal class InAppWebViewPrewarmManagerImplTest { } } + @Test + fun `an attempt that trips the no-layers recheck does not consume the one-shot`() { + // The fresh no-layers config lands while this attempt is suspended reading the + // saved configuration — before the CAS. Per the doc comment, only an attempt that + // reaches the engine may consume the one-shot: this one must not. + every { DbManager.listenConfigurations() } answers { + service.prewarmResources(configWith(InAppStub.getInApp())) + flowOf(configuration) + } + + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + + // A later, valid attempt must still be able to warm — the one-shot survived. + every { DbManager.listenConfigurations() } returns flowOf(configuration) + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + @Test fun `prewarmResources releases warm webview when config has no webview inapps`() { service.prewarmResources(configWith(InAppStub.getInApp())) @@ -339,6 +370,34 @@ internal class InAppWebViewPrewarmManagerImplTest { verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } } + @Test + fun `terminate aborts the engine and blocks later attempts, same as a real show`() { + service.terminate() + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `terminate stops an in-flight settle poll like onRealShowWillStart does`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + var count = 0 + every { engine.evaluateJavaScript(any(), any()) } answers { + count++ + secondArg<(String?) -> Unit>().invoke("\"$count:100\"") + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(2_500) + service.terminate() + advanceTimeBy(60_000) + + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.release() } + } + @Test fun `prewarmOnInit warms from cached config without validation pipeline`() { every { MindboxPreferences.inAppConfig } returns cachedConfigJson() @@ -355,6 +414,70 @@ internal class InAppWebViewPrewarmManagerImplTest { } } + @Test + fun `prewarmOnInit awaits the migration lock before reading the cached config`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson() + + service.prewarmOnInit() + + coVerify(exactly = 1) { InitializeLock.await(InitializeLock.State.MIGRATION) } + } + + @Test + fun `prewarmOnInit is a no-op once a prior attempt already reached the engine`() { + // Reaches the engine and consumes the one-shot. + service.prewarmResources(configWith(webViewInApp)) + + service.prewarmOnInit() + + // Not even the cheap cached-config read happens on the short-circuited path. + verify(exactly = 0) { MindboxPreferences.inAppConfig } + coVerify(exactly = 0) { InitializeLock.await(any()) } + } + + @Test + fun `prewarmOnInit does not warm a modal whose first layer is not webview`() { + // Same gate as the real pipeline (InAppMapper): webview must be the FIRST layer of + // the modal to ever become a WebView in-app; an image-then-webview modal never will. + every { MindboxPreferences.inAppConfig } returns """ + { + "inapps": [ + { + "id": "cached-webview", + "sdkVersion": { "min": 1, "max": null }, + "targeting": { "${'$'}type": "true" }, + "form": { + "variants": [ + { + "${'$'}type": "modal", + "content": { + "background": { + "layers": [ + { "${'$'}type": "image" }, + { + "${'$'}type": "webview", + "baseUrl": "https://inapp.local/popup", + "contentUrl": "https://mobile-static.mindbox.ru/content/index.html", + "params": { "formId": "159510" } + } + ] + }, + "elements": [] + } + } + ] + } + } + ] + } + """.trimIndent() + + service.prewarmOnInit() + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + @Test fun `prewarmOnInit does nothing without cached config`() { every { MindboxPreferences.inAppConfig } returns ""