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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion kmp-common-sdk
Submodule kmp-common-sdk updated 24 files
+2 −2 .github/bump_versions_in_release_branch.sh
+16 −0 .github/dependabot.yml
+14 −10 .github/git-release-ci.sh
+23 −0 .github/workflows/gitleaks-secrets-validate.yml
+26 −9 .github/workflows/lint_unitTests_build.yml
+30 −13 .github/workflows/manual-prepare_release_branch.yml
+8 −1 .github/workflows/publish-from-master-or-support.yml
+11 −2 .github/workflows/publish-manual.yml
+48 −27 .github/workflows/publish-reusable.yml
+16 −11 .github/workflows/release-version-check.yml
+2 −0 gradle/libs.versions.toml
+3 −0 make-ios-framework.sh
+6 −0 mindbox-common/build.gradle.kts
+171 −0 mindbox-common/src/androidMain/kotlin/cloud/mindbox/mobile_sdk/inapp/webview/InAppWebViewPrewarmEngine.kt
+102 −29 mindbox-common/src/androidMain/kotlin/cloud/mindbox/mobile_sdk/inapp/webview/WebViewAndroid.kt
+14 −0 mindbox-common/src/androidMain/kotlin/cloud/mindbox/mobile_sdk/inapp/webview/WebViewCachePolicy.kt
+1 −3 mindbox-common/src/androidMain/kotlin/cloud/mindbox/mobile_sdk/utils/Time.kt
+18 −0 mindbox-common/src/androidUnitTest/kotlin/cloud/mindbox/mobile_sdk/inapp/webview/WebViewCachePolicyTest.kt
+0 −1 mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/annotations/InternalMindboxApi.kt
+153 −0 mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/inapp/webview/InAppWebViewPrewarmPlanner.kt
+6 −0 mindbox-common/src/commonMain/kotlin/cloud/mindbox/mobile_sdk/inapp/webview/WebViewInterfaces.kt
+196 −0 mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/inapp/webview/InAppWebViewPrewarmPlannerTest.kt
+0 −1 mindbox-common/src/commonTest/kotlin/cloud/mindbox/mobile_sdk/utils/TimeTest.kt
+1 −3 mindbox-common/src/iosMain/kotlin/cloud/mindbox/mobile_sdk/utils/Time.kt
4 changes: 4 additions & 0 deletions sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
enotniy marked this conversation as resolved.
pushConverters = selectPushServiceHandler(pushServices)
logI(
"init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " +
Expand Down Expand Up @@ -1377,6 +1380,7 @@ public object Mindbox : MindboxLog {
private fun softReinitialization(
context: Context,
) {
MindboxDI.appModule.inAppWebViewPrewarmManager.terminate()
Comment thread
enotniy marked this conversation as resolved.
mindboxScope.cancel()
DbManager.removeAllEventsFromQueue()
BackgroundWorkManager.cancelAllWork(context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -163,7 +192,8 @@ internal fun DataModule(
mobileConfigSettingsManager = mobileConfigSettingsManager,
integerPositiveValidator = integerPositiveValidator,
inappSettingsManager = inappSettingsManager,
featureToggleManager = featureToggleManager
featureToggleManager = featureToggleManager,
inAppWebViewPrewarmManager = inAppWebViewPrewarmManager
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -21,6 +26,6 @@ internal class FeatureToggleManagerImpl : FeatureToggleManager {
}

override fun isEnabled(key: String): Boolean {
return toggles[key] ?: true
return toggles[key] ?: FEATURE_TOGGLE_DEFAULT
}
}
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String>): 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
Comment thread
enotniy marked this conversation as resolved.

/**
* 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)
}
}
Loading