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/.gitmodules b/.gitmodules index 8112ba12..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 + branch = develop \ No newline at end of file diff --git a/kmp-common-sdk b/kmp-common-sdk index 80e199ff..6cfaa316 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 80e199ff1d25f1bed6283287b4a89be6e3e65e10 +Subproject commit 6cfaa316f450a08fa936bfac681af2cbe75bff52 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..6662d32d 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.inAppWebViewPrewarmManager.prewarmOnInit() pushConverters = selectPushServiceHandler(pushServices) logI( "init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + @@ -1377,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/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 6695ffa4..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,9 +26,14 @@ 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 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 +150,30 @@ 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 = { webViewCachePolicy.isCacheEnabled } + ), + 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(), + featureToggleManager = featureToggleManager, + webViewCachePolicy = webViewCachePolicy + ) + } + override val mobileConfigRepository: MobileConfigRepository by lazy { MobileConfigRepositoryImpl( inAppMapper = inAppMapper, @@ -163,7 +192,8 @@ internal fun DataModule( mobileConfigSettingsManager = mobileConfigSettingsManager, integerPositiveValidator = integerPositiveValidator, inappSettingsManager = inappSettingsManager, - featureToggleManager = featureToggleManager + featureToggleManager = featureToggleManager, + 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 4d5f8499..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 @@ -64,6 +64,8 @@ internal interface DataModule : MindboxModule { val sessionStorageManager: SessionStorageManager 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/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/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..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,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.InAppWebViewPrewarmManager 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 inAppWebViewPrewarmManager: InAppWebViewPrewarmManager ) : 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). + inAppWebViewPrewarmManager.prewarmResources(updatedInAppConfig) mindboxLogI(message = "Providing config: $updatedInAppConfig") } } 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 new file mode 100644 index 00000000..e6ad0036 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt @@ -0,0 +1,423 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.InitializeLock +import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.FEATURE_TOGGLE_DEFAULT +import cloud.mindbox.mobile_sdk.inapp.data.managers.InAppWebViewLearnedHostsStore +import cloud.mindbox.mobile_sdk.inapp.data.managers.PREWARM_INAPP_WEBVIEW_FEATURE +import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmEngine +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmLayer +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmPlanner +import cloud.mindbox.mobile_sdk.inapp.webview.WebViewController +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import cloud.mindbox.mobile_sdk.managers.DbManager +import cloud.mindbox.mobile_sdk.managers.GatewayManager +import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.getShortUserAgent +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponseBlank +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import cloud.mindbox.mobile_sdk.utils.loggingRunCatchingSuspending +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray +import org.json.JSONTokener +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration.Companion.milliseconds + +/** + * Production prewarm for webview in-apps. Two stages, both driven by the mobile + * config (no hardcoded hosts or URLs): + * + * 1. SDK init — head start from the previous launch's cached config; + * 2. config downloaded/parsed — [prewarmResources] (releases the warm instance + * when the config proves there are no webview in-apps). + * + * The resource prewarm loads a preconnect page (origins from the config layers + + * API domain + hosts learned from previous shows) and then the layer's real content + * page with the official prewarm params on its URL, so the shared HTTP cache and + * connection pool are warm before the first show. A real show always preempts the prewarm + * ([onRealShowWillStart]): the hidden WebView is destroyed and the network is + * handed over. Unlike iOS there is no instance reuse — Android shares the renderer + * process, so a warm instance buys nothing (measured). + */ +internal interface InAppWebViewPrewarmManager { + + /** Prewarm stage 1: head start from the cached config. Call once at SDK init. */ + fun prewarmOnInit() + + /** Prewarm stage 2: warm what [config]'s webview in-apps need (or release when none). */ + fun prewarmResources(config: InAppConfig) + + /** + * A real WEBVIEW show is starting: abort the prewarm and free its WebView. + * + * Deliberately narrow — image/snackbar shows do not preempt: their downloads are small + * next to the settle window (network-idle release frees the WebView within seconds), + * while aborting here is terminal and would forfeit the whole launch's byendpoint warm + * because an unrelated banner happened to show first. + */ + fun onRealShowWillStart() + + /** Records the https hosts the shown page actually used (learned-hosts store). */ + @OptIn(InternalMindboxApi::class) + fun captureObservedHosts(controller: WebViewController) + + /** + * Terminates the prewarm subsystem for good. Call before cancelling the coroutine scope + * this manager runs on (SDK teardown, soft reinitialization): a settle-poll job killed by + * scope cancellation never reaches its own tail-end `engine.release()`, so without this + * the warm WebView would leak until process death. + */ + fun terminate() +} + +@OptIn(InternalMindboxApi::class) +internal class InAppWebViewPrewarmManagerImpl( + private val engine: InAppWebViewPrewarmEngine, + private val mobileConfigSerializationManager: MobileConfigSerializationManager, + private val gatewayManager: Lazy, + private val inAppValidator: InAppValidator, + private val webViewLayerValidator: WebViewLayerValidator, + private val learnedHostsStore: InAppWebViewLearnedHostsStore, + private val featureToggleManager: FeatureToggleManager, + private val webViewCachePolicy: InAppWebViewCachePolicy +) : InAppWebViewPrewarmManager { + + companion object { + // Hard cap on how long the hidden WebView may live after the content page was + // handed to it; normally the network-idle poll below releases it much earlier + // (cache/sockets survive at the profile level, so keeping it alive buys nothing). + private const val SETTLE_RELEASE_MS = 30_000L + + // Network-idle release: the page is considered settled when the Resource Timing + // entry count is stable across consecutive polls AND the last completed resource + // finished at least IDLE_QUIET_MS ago. Entries appear only on completion, so the + // quiet window (not the stable count alone) is what guards against an in-flight + // download; a transfer slower than the window can still be cut — same worst case + // as the hard cap, the show just re-fetches that file. + private const val IDLE_POLL_MS = 1_000L + private const val IDLE_QUIET_MS = 2_000L + private const val IDLE_STABLE_POLLS = 2 + + // Returns ":" (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() { + // Cheap short-circuit before paying for the config read/parse below: a repeat + // initialize() call must not redo work a prior attempt already finished. + if (hasStartedResourcePrewarm.get()) return + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + // Other init-time readers wait for this; without it, a migration that fails + // and triggers a softReset could erase the cached config out from under a + // prewarm that already read it. + InitializeLock.await(InitializeLock.State.MIGRATION) + val cachedConfig = MindboxPreferences.inAppConfig + if (cachedConfig.isBlank()) { + // Nothing to prewarm, but the cache toggle still needs a decision for + // the first real show — latch it to the default now, off the main + // thread, instead of parsing lazily (nothing to parse anyway) the + // moment isCacheEnabled is first read during that show. + webViewCachePolicy.prime(null) + return@loggingRunCatchingSuspending + } + val layers = webViewLayers(cachedConfig) + if (layers.isEmpty()) return@loggingRunCatchingSuspending + mindboxLogI("[WebView] Prewarm: head start from cached config (${layers.size} webview layer(s))") + startResourcePrewarm(layers) + } + } + } + + override fun prewarmResources(config: InAppConfig) { + // A fresh config that turns the toggle off also kills a stage-1 instance started + // under the previous launch's config. + if (!featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE)) { + mindboxLogI("[WebView] Prewarm: feature toggle is off, releasing warm WebView") + releaseWarmWebView() + return + } + val layers = config.inApps + .flatMap { inApp -> inApp.form.variants } + .filterIsInstance() + .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") + releaseWarmWebView() + return + } + latestConfigHasNoLayers.set(false) + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + startResourcePrewarm(layers) + } + } + } + + private fun releaseWarmWebView() { + latestConfigHasNoLayers.set(true) + settleJob.getAndSet(null)?.cancel() + engine.release() + } + + override fun onRealShowWillStart() = abortPermanently() + + override fun terminate() = abortPermanently() + + private fun abortPermanently() { + hasAborted.set(true) + settleJob.getAndSet(null)?.cancel() + // Terminal: the engine latches synchronously, so a prewarm load already posted from + // a background thread cannot resurrect the WebView afterward. + engine.abort() + } + + override fun captureObservedHosts(controller: WebViewController) { + controller.evaluateJavaScript(InAppWebViewPrewarmPlanner.observedResourceHostsScript) { result -> + val observedHosts = parseObservedHosts(result) + if (observedHosts.isEmpty()) return@evaluateJavaScript + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + val endpointId = currentConfiguration()?.endpointId ?: return@loggingRunCatchingSuspending + learnedHostsStore.merge(endpointId, observedHosts) + mindboxLogI("[WebView] Prewarm: learned hosts for $endpointId: $observedHosts") + } + } + } + } + + /** + * Runs at most once per process — but only an attempt that actually reaches the engine + * consumes the one-shot: a transient configuration read failure or an unplannable + * cached config must not block a later attempt from a valid fresh config. + * + * By design the one-shot also means stage 2 does NOT re-warm when stage 1 already ran + * from a cached config whose URLs have since changed — the head start beats freshness + * for this launch, and the next launch heals with the new cached config. + */ + private suspend fun startResourcePrewarm(layers: List) { + if (hasAborted.get()) return + + val configuration = currentConfiguration() ?: run { + mindboxLogW("[WebView] Prewarm: no saved configuration, skipping") + return + } + val plan = InAppWebViewPrewarmPlanner.buildPlan( + layers = layers, + extraOrigins = listOf(configuration.domain) + learnedHostsStore.hosts(configuration.endpointId) + ) ?: run { + mindboxLogW("[WebView] Prewarm: no valid webview layer urls in config, skipping") + return + } + // Re-check after the configuration read suspension BEFORE the one-shot CAS: an + // attempt that stumbles here must not consume it, or a fresh no-layers config + // landing mid-suspension would burn the one-shot on a prewarm that never reaches + // the engine, per this function's own doc comment. + if (hasAborted.get() || latestConfigHasNoLayers.get()) return + if (!hasStartedResourcePrewarm.compareAndSet(false, true)) return + val userAgentSuffix = configuration.getShortUserAgent() + + mindboxLogI("[WebView] Prewarm: preconnect to ${plan.preconnectOrigins.joinToString(",")} under ${plan.baseUrl}") + engine.loadPreconnectPage(plan.preconnectHtml, plan.baseUrl, userAgentSuffix) + + val html = runCatching { gatewayManager.value.fetchWebViewContent(plan.contentUrl) } + .getOrElse { error -> + mindboxLogW("[WebView] Prewarm: content page fetch failed: $error") + scheduleSettleRelease() + return + } + // Re-check both verdicts after the suspension point: a real show may have taken the + // network over, or a fresh config may have proven there is nothing to warm — either + // way the fetched content must not resurrect a WebView. The no-layers path must + // also RELEASE: the preconnect load above may have already created the WebView, and + // with no settle poll scheduled on this path nothing else would ever free it. + if (hasAborted.get()) return + if (latestConfigHasNoLayers.get()) { + engine.release() + return + } + // Official prewarm contract on the document URL: a runtime that knows it boots + // tracker-only; an older runtime ignores it (plain page warm, no byendpoint). + val prewarmBaseUrl = InAppWebViewPrewarmPlanner.prewarmContentBaseUrl( + baseUrl = plan.baseUrl, + endpointId = configuration.endpointId, + deviceUuid = MindboxPreferences.deviceUuid + ) + mindboxLogI("[WebView] Prewarm: content page under $prewarmBaseUrl, endpoint ${configuration.endpointId}") + engine.loadContentPage( + html = html, + baseUrl = prewarmBaseUrl, + userAgentSuffix = userAgentSuffix + ) + scheduleSettleRelease() + } + + private fun scheduleSettleRelease() { + // A terminal preempt may have landed while the caller was suspended — never store a + // poll job into the slot onRealShowWillStart() just cleared (it would probe the main + // looper for 30s during the live show). + if (hasAborted.get()) return + val job = Mindbox.mindboxScope.launch { + // Belt-and-suspenders with the per-tick check below: an abort landing between + // the guard above and this coroutine's first resumption must not run even one + // poll tick. + if (hasAborted.get()) return@launch + // Budget in poll units instead of a wall clock read: the whole loop runs on + // virtual time in tests. A null probe is charged the full probe timeout so the + // cap stays a real wall-clock bound even when the evaluate callback never fires + // (blocked main thread, renderer stall — the pathological cases the cap exists + // for). A fast-but-garbage probe gets overcharged and releases early, which is + // the safe direction: garbage means the page or WebView is not answering. + val budgetPolls = (SETTLE_RELEASE_MS / IDLE_POLL_MS).toInt() + val timeoutCharge = (IDLE_QUIET_MS / IDLE_POLL_MS).toInt() + var polls = 0 + var lastCount = -1 + var stablePolls = 0 + var idle = false + while (polls < budgetPolls) { + delay(IDLE_POLL_MS.milliseconds) + polls++ + if (hasAborted.get()) return@launch + val probe = probeResourceState() + if (probe == null) { + polls += timeoutCharge + continue + } + if (probe.entryCount == lastCount) { + stablePolls++ + } else { + stablePolls = 0 + lastCount = probe.entryCount + } + if (stablePolls >= IDLE_STABLE_POLLS && probe.msSinceLastResponseEnd > IDLE_QUIET_MS) { + idle = true + break + } + } + mindboxLogI( + "[WebView] Prewarm: settle release after ~${polls * IDLE_POLL_MS}ms of budget " + + if (idle) "(network idle, $lastCount resources)" else "(hard cap)" + ) + engine.release() + } + settleJob.getAndSet(job)?.cancel() + if (hasAborted.get()) { + settleJob.getAndSet(null)?.cancel() + } + } + + private data class ResourceProbe(val entryCount: Int, val msSinceLastResponseEnd: Long) + + /** + * One Resource Timing probe on the prewarm page. Null when the probe cannot run or + * returns garbage (WebView gone/aborted, page not ready) — callers just keep polling + * until the hard cap. The 2s timeout guards against a callback that never fires. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun probeResourceState(): ResourceProbe? = + withTimeoutOrNull(IDLE_QUIET_MS.milliseconds) { + suspendCancellableCoroutine { continuation -> + engine.evaluateJavaScript(IDLE_PROBE_JS) { rawResult -> + // evaluateJavascript JSON-quotes string results: "\"6:3456\"". + val parts = rawResult?.trim('"')?.split(':') + val probe = if (parts?.size == 2) { + val count = parts[0].toIntOrNull() + val sinceLast = parts[1].toLongOrNull() + if (count != null && sinceLast != null) ResourceProbe(count, sinceLast) else null + } else { + null + } + if (continuation.isActive) continuation.resume(probe) {} + } + } + } + + private suspend fun currentConfiguration(): Configuration? = + runCatching { DbManager.listenConfigurations().first() }.getOrNull() + + /** + * Light parse of the cached config: only webview layer urls, no targeting checks. + * + * The toggle is read from THIS cached config, not [featureToggleManager]: stage 1 races + * the fresh config download, so the manager may still hold last launch's (or no) state + * when this runs. + */ + private fun webViewLayers(configString: String): List { + val configBlank = mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) + ?: return emptyList() + // Shares this parse with the cache toggle instead of it deserializing the same + // cached config a second time; a no-op once the toggle has already latched. + webViewCachePolicy.prime(configBlank) + if (!isPrewarmEnabled(configBlank)) { + mindboxLogI("[WebView] Prewarm: feature toggle is off in the cached config, skipping head start") + return emptyList() + } + return configBlank.inApps.orEmpty() + // Same version gate as the real pipeline: in-apps for other SDK versions may + // carry form formats this version cannot even deserialize. + .asSequence() + .filter { inAppBlank -> inAppValidator.validateInAppVersion(inAppBlank) } + .flatMap { inAppBlank -> + mobileConfigSerializationManager.deserializeToInAppFormDto(inAppBlank.form) + ?.variants.orEmpty() + .filterIsInstance() + // 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 = + configBlank.settings?.featureToggles?.toggles?.get(PREWARM_INAPP_WEBVIEW_FEATURE) ?: FEATURE_TOGGLE_DEFAULT + + /** + * `evaluateJavascript` returns the JS value JSON-encoded; the probe returns a string + * containing a JSON array, so unwrap the outer string and then parse the array. + */ + private fun parseObservedHosts(result: String?): List = 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..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 @@ -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,8 @@ 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 import androidx.lifecycle.ProcessLifecycleOwner @@ -77,6 +81,11 @@ 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 hasInitialized = false + private var pendingReadyCheckFailure: String? = null private var motionService: MotionServiceProtocol? = null @@ -89,6 +98,8 @@ 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 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 } @@ -278,6 +289,7 @@ internal class WebViewInAppViewHolder( } private fun handleInitAction(controller: WebViewController): String { + hasInitialized = true stopTimer() wrapper.inAppActionCallbacks.onInAppShown.onShown() val mindboxView = currentMindboxView ?: run { @@ -416,7 +428,12 @@ 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 = webViewCachePolicy.isCacheEnabled, + log = { message -> mindboxLogI("[WebView] $message") } + ) val view: WebViewPlatformView = controller.view view.layoutParams = RelativeLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -426,7 +443,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 +532,79 @@ 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 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; + // 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 -> + 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 + } + } + ) + } + + /** + * 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", - tags = wrapper.tags - ) - inAppController.close() + // 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 returned unexpected response: $response", + tags = wrapper.tags + ) + } else { + mindboxLogW("evaluateJavaScript miss after teardown (ignored): $response") + } false } } @@ -605,6 +684,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. + webViewPrewarmManager.onRealShowWillStart() val controller: WebViewController = createWebViewController(layer) webViewController = controller @@ -691,14 +772,26 @@ internal class WebViewInAppViewHolder( private fun onContentPageLoaded(content: WebViewHtmlContent) { webViewController?.let { controller -> - controller.executeOnViewThread { - controller.loadContent(content) - } + 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 { @@ -763,14 +856,20 @@ internal class WebViewInAppViewHolder( hapticFeedbackExecutor.cancel() motionService?.stopMonitoring() stopTimer() + readyChecker?.cancel() + 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) 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..3030e87a --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt @@ -0,0 +1,69 @@ +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 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(" ", "")) + 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..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 @@ -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), + inAppWebViewPrewarmManager = mockk(relaxed = true) ) } } 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 new file mode 100644 index 00000000..c7faa4e3 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt @@ -0,0 +1,590 @@ +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 +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.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 +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.spyk +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.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class, InternalMindboxApi::class) +internal class InAppWebViewPrewarmManagerImplTest { + + 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 featureToggleManager: FeatureToggleManager + private lateinit var mobileConfigSerializationManager: MobileConfigSerializationManager + private lateinit var webViewCachePolicy: InAppWebViewCachePolicy + private lateinit var service: InAppWebViewPrewarmManagerImpl + + @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" + mockkObject(InitializeLock) + coEvery { InitializeLock.await(any()) } returns Unit + engine = mockk(relaxed = true) + gatewayManager = mockk(relaxed = true) + coEvery { gatewayManager.fetchWebViewContent(any()) } returns "" + 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 = mobileConfigSerializationManager, + 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") + }, + featureToggleManager = featureToggleManager, + webViewCachePolicy = webViewCachePolicy + ) + } + + @After + fun tearDown() { + unmockkObject(Mindbox) + unmockkObject(DbManager) + unmockkObject(MindboxPreferences) + unmockkObject(InitializeLock) + } + + 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 `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())) + + verify(exactly = 1) { engine.release() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + 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() + 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 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() + + 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 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 "" + + service.prewarmOnInit() + + 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()) } + } + + @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( + 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(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": [ + { + "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": [] + } + } + ] + } + } + ]$settings + } + """.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) + } +}