From 70913b08ede93f7380dbb4706dafa0a39a6bb094 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 18 May 2026 17:54:46 +0300 Subject: [PATCH 01/37] MOBILE-78: Add MindboxLifecycleInitializer --- gradle/libs.versions.toml | 2 + sdk/build.gradle | 1 + sdk/src/main/AndroidManifest.xml | 10 + .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 251 ++-- .../view/WebViewInappViewHolder.kt | 4 +- .../mobile_sdk/managers/LifecycleManager.kt | 284 +++-- .../managers/MindboxLifecycleInitializer.kt | 31 + .../managers/LifecycleManagerTest.kt | 1032 +++++++++++++++++ .../MindboxLifecycleInitializerTest.kt | 132 +++ .../MindboxSetupLifecycleManagerTest.kt | 140 +++ 10 files changed, 1661 insertions(+), 226 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxSetupLifecycleManagerTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ce4ed8e..ae3ad7fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ volley = "1.2.1" gson = "2.8.9" work_manager = "2.8.1" androidx_lifecycle = "2.8.7" +androidx_startup = "1.2.0" androidx_core_ktx = "1.13.0" androidx_annotations = "1.3.0" constraint_layout = "2.1.4" @@ -86,6 +87,7 @@ room_ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room_compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } work_manager = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work_manager" } androidx_lifecycle = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "androidx_lifecycle" } +androidx_startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidx_startup" } hms_push = { group = "com.huawei.hms", name = "push", version.ref = "hms_push" } hms_ads_identifier = { group = "com.huawei.hms", name = "ads-identifier", version.ref = "hms_ads_identifier" } constraint_layout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraint_layout" } diff --git a/sdk/build.gradle b/sdk/build.gradle index 9d7d78b7..79789515 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -87,6 +87,7 @@ dependencies { // Handle app lifecycle implementation libs.androidx.lifecycle + implementation libs.androidx.startup implementation libs.threetenabp // Glide diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml index e50bafe2..92ba798a 100644 --- a/sdk/src/main/AndroidManifest.xml +++ b/sdk/src/main/AndroidManifest.xml @@ -9,6 +9,16 @@ + + + + Unit>() private val deviceUuidCallbacks = ConcurrentHashMap Unit>() - private lateinit var lifecycleManager: LifecycleManager + private val lifecycleManager: LifecycleManager? get() = LifecycleManager.instance private val userVisitManager: UserVisitManager by mindboxInject { userVisitManager } private val timeProvider by mindboxInject { timeProvider } @@ -554,152 +552,139 @@ public object Mindbox : MindboxLog { context: Context, configuration: MindboxConfiguration, pushServices: List, - ) { - LoggingExceptionHandler.runCatching { - verifyThreadExecution(methodName = "init") - val currentProcessName = context.getCurrentProcessName() - if (!context.isMainProcess(currentProcessName)) { - logW("Skip Mindbox init not in main process! Current process $currentProcessName") - return@runCatching - } - Stopwatch.start(Stopwatch.INIT_SDK) + ): Unit = loggingRunCatching { + verifyThreadExecution(methodName = "init") + val currentProcessName = context.getCurrentProcessName() + if (!context.isMainProcess(currentProcessName)) { + logW("Skip Mindbox init not in main process! Current process $currentProcessName") + return@loggingRunCatching + } + Stopwatch.start(Stopwatch.INIT_SDK) - initComponents(context.applicationContext) - pushConverters = selectPushServiceHandler(pushServices) - logI("init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + + initComponents(context.applicationContext) + pushConverters = selectPushServiceHandler(pushServices) + logI( + "init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + "configuration: $configuration, pushServices: " + pushServices.joinToString(", ") { it.javaClass.simpleName } + - ", SdkVersion:${getSdkVersion()}, CommonSdkVersion:${MindboxCommon.VERSION_NAME}") + ", SdkVersion:${getSdkVersion()}, CommonSdkVersion:${MindboxCommon.VERSION_NAME}", + ) - if (!firstInitCall.get()) { - InitializeLock.reset(InitializeLock.State.SAVE_MINDBOX_CONFIG) - } else { - userVisitManager.saveUserVisit() - } + if (!firstInitCall.get()) { + InitializeLock.reset(InitializeLock.State.SAVE_MINDBOX_CONFIG) + } else { + userVisitManager.saveUserVisit() + } - initScope.launch { - InitializeLock.await(InitializeLock.State.MIGRATION) - val checkResult = checkConfig(configuration) - val validatedConfiguration = validateConfiguration(configuration) - DbManager.saveConfigurations(Configuration(configuration)) - logI("init. checkResult: $checkResult") - if (checkResult != ConfigUpdate.NOT_UPDATED && !MindboxPreferences.isFirstInitialize) { - logI("init. softReinitialization") - softReinitialization(context.applicationContext) - } + launchInitJob(context, configuration, pushServices) + setupLifecycleManager(context) + attachLifecycleCallbacks() + } - if (checkResult == ConfigUpdate.UPDATED) { - setPushServiceHandler(context, pushServices) - firstInitialization(context.applicationContext, validatedConfiguration) + private fun launchInitJob( + context: Context, + configuration: MindboxConfiguration, + pushServices: List, + ) { + initScope.launch { + InitializeLock.await(InitializeLock.State.MIGRATION) + val checkResult = checkConfig(configuration) + val validatedConfiguration = validateConfiguration(configuration) + DbManager.saveConfigurations(Configuration(configuration)) + logI("init. checkResult: $checkResult") + if (checkResult != ConfigUpdate.NOT_UPDATED && !MindboxPreferences.isFirstInitialize) { + logI("init. softReinitialization") + softReinitialization(context.applicationContext) + } - val isTrackVisitNotSent = Mindbox::lifecycleManager.isInitialized && - !lifecycleManager.isTrackVisitSent() - if (isTrackVisitNotSent) { - MindboxLoggerImpl.d(this, "Track visit event with source $DIRECT") - sendTrackVisitEvent(context.applicationContext, DIRECT) - } - } else { - mindboxScope.launch { - setPushServiceHandler(context, pushServices) - } - MindboxEventManager.sendEventsIfExist(context.applicationContext) + if (checkResult == ConfigUpdate.UPDATED) { + setPushServiceHandler(context, pushServices) + firstInitialization(context.applicationContext, validatedConfiguration) + } else { + mindboxScope.launch { + setPushServiceHandler(context, pushServices) } - MindboxPreferences.uuidDebugEnabled = configuration.uuidDebugEnabled - }.initState(InitializeLock.State.SAVE_MINDBOX_CONFIG) - .invokeOnCompletion { throwable -> - if (throwable == null) { - if (firstInitCall.get()) { - val activity = context as? Activity - if (activity != null && lifecycleManager.isCurrentActivityResumed) { - inAppMessageManager.registerCurrentActivity(activity) - mindboxScope.launch { - inAppMutex.withLock { - logI("Start inapp manager after init. firstInitCall: ${firstInitCall.get()}") - if (!firstInitCall.getAndSet(false)) return@launch - inAppMessageManager.listenEventAndInApp() - inAppMessageManager.initLogs() - MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) - inAppMessageManager.requestConfig().join() - } - } + MindboxEventManager.sendEventsIfExist(context.applicationContext) + } + MindboxPreferences.uuidDebugEnabled = configuration.uuidDebugEnabled + }.initState(InitializeLock.State.SAVE_MINDBOX_CONFIG) + .invokeOnCompletion { throwable -> + if (throwable == null && firstInitCall.get()) { + val activity = context as? Activity + if (activity != null && lifecycleManager?.isCurrentActivityResumed == true) { + inAppMessageManager.registerCurrentActivity(activity) + mindboxScope.launch { + inAppMutex.withLock { + logI("Start inapp manager after init. firstInitCall: ${firstInitCall.get()}") + if (!firstInitCall.getAndSet(false)) return@launch + inAppMessageManager.listenEventAndInApp() + inAppMessageManager.initLogs() + MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) + inAppMessageManager.requestConfig().join() } } } } - // Handle back app in foreground - (context.applicationContext as? Application)?.apply { - val applicationLifecycle = ProcessLifecycleOwner.get().lifecycle + } + } - if (!Mindbox::lifecycleManager.isInitialized) { - val activity = context as? Activity - val isApplicationResumed = applicationLifecycle.currentState == RESUMED - if (isApplicationResumed && activity == null) { - logE("Incorrect context type for calling init in this place") - } - if (isApplicationResumed || context !is Application) { - logW( - "We recommend to call Mindbox.init() synchronously from " + - "Application.onCreate. If you can't do so, don't forget to " + - "call Mindbox.initPushServices from Application.onCreate", - ) + private fun setupLifecycleManager(context: Context) { + if (LifecycleManager.isRegister) { + if (!firstInitCall.get()) { + lifecycleManager?.scheduleReinitTrackVisit() + } + return + } + + logW("Register LifecycleManager (startup initializer not found)") + LifecycleManager.register(context) + } + + private fun attachLifecycleCallbacks() { + lifecycleManager?.callbacks = object : LifecycleManager.Callbacks { + override fun onActivityStarted(activity: Activity) { + UuidCopyManager.onAppMovedToForeground(activity) + mindboxScope.launch { + if (!MindboxPreferences.isFirstInitialize) { + updateAppInfo(activity.applicationContext) } + } + } - logI("init. init lifecycleManager") - lifecycleManager = LifecycleManager( - currentActivityName = activity?.javaClass?.name, - currentIntent = activity?.intent, - isAppInBackground = !isApplicationResumed, - onActivityStarted = { startedActivity -> - UuidCopyManager.onAppMovedToForeground(startedActivity) - mindboxScope.launch { - if (!MindboxPreferences.isFirstInitialize) { - updateAppInfo(startedActivity.applicationContext) - } - } - }, - onActivityPaused = { pausedActivity -> - inAppMessageManager.onPauseCurrentActivity(pausedActivity) - }, - onActivityResumed = { resumedActivity -> - inAppMessageManager.onResumeCurrentActivity( - resumedActivity - ) - if (firstInitCall.get()) { - mindboxScope.launch { - InitializeLock.await(InitializeLock.State.SAVE_MINDBOX_CONFIG) - inAppMutex.withLock { - logI("Start inapp manager after resume activity. firstInitCall: ${firstInitCall.get()}") - if (!firstInitCall.getAndSet(false)) return@launch - inAppMessageManager.listenEventAndInApp() - inAppMessageManager.initLogs() - MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) - inAppMessageManager.requestConfig().join() - } - } - } - }, - onActivityStopped = { resumedActivity -> - inAppMessageManager.onStopCurrentActivity(resumedActivity) - }, - onTrackVisitReady = { source, requestUrl -> - sessionStorageManager.hasSessionExpired() - eventScope.launch { - sendTrackVisitEvent( - MindboxDI.appModule.appContext, - source, - requestUrl - ) - } + override fun onActivityPaused(activity: Activity) { + inAppMessageManager.onPauseCurrentActivity(activity) + } + + override fun onActivityResumed(activity: Activity) { + inAppMessageManager.onResumeCurrentActivity(activity) + if (firstInitCall.get()) { + mindboxScope.launch { + InitializeLock.await(InitializeLock.State.SAVE_MINDBOX_CONFIG) + inAppMutex.withLock { + logI("Start in-app manager after resume activity. firstInitCall: ${firstInitCall.get()}") + if (!firstInitCall.getAndSet(false)) return@launch + inAppMessageManager.listenEventAndInApp() + inAppMessageManager.initLogs() + MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) + inAppMessageManager.requestConfig().join() } - ) - } else { - unregisterActivityLifecycleCallbacks(lifecycleManager) - applicationLifecycle.removeObserver(lifecycleManager) - lifecycleManager.wasReinitialized() + } } + } - registerActivityLifecycleCallbacks(lifecycleManager) - applicationLifecycle.addObserver(lifecycleManager) + override fun onActivityStopped(activity: Activity) { + inAppMessageManager.onStopCurrentActivity(activity) + } + + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + sessionStorageManager.hasSessionExpired() + eventScope.launch { + sendTrackVisitEvent( + MindboxDI.appModule.appContext, + source, + requestUrl, + ) + } } } } @@ -889,8 +874,8 @@ public object Mindbox : MindboxLog { */ public fun onNewIntent(intent: Intent?): Unit = LoggingExceptionHandler.runCatching { MindboxLoggerImpl.d(this, "onNewIntent. intent: $intent") - if (Mindbox::lifecycleManager.isInitialized) { - lifecycleManager.onNewIntent(intent) + if (lifecycleManager != null) { + lifecycleManager?.onNewIntent(intent) } else { MindboxLoggerImpl.d(this, "onNewIntent. LifecycleManager is not initialized. Skipping.") } 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 41d8ba2e..e86c6770 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 @@ -648,7 +648,7 @@ internal class WebViewInAppViewHolder( errorDescription = "Failed to fetch HTML content for In-App", throwable = e ) - inAppController.close() + controller.executeOnViewThread { inAppController.close() } } } ?: run { inAppFailureTracker.sendFailureWithContext( @@ -656,7 +656,7 @@ internal class WebViewInAppViewHolder( failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = "WebView content URL is null" ) - inAppController.close() + controller.executeOnViewThread { inAppController.close() } } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt index 858cb1e4..df8dc7b1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt @@ -2,11 +2,16 @@ package cloud.mindbox.mobile_sdk.managers import android.app.Activity import android.app.Application +import android.content.Context import android.content.Intent import android.os.Bundle import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import cloud.mindbox.mobile_sdk.Mindbox.logE +import cloud.mindbox.mobile_sdk.Mindbox.logW +import cloud.mindbox.mobile_sdk.logger.MindboxLog import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.models.DIRECT import cloud.mindbox.mobile_sdk.models.LINK @@ -16,186 +21,283 @@ import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import java.util.Timer import kotlin.concurrent.timer -internal class LifecycleManager( +internal class LifecycleManager internal constructor( private var currentActivityName: String?, private var currentIntent: Intent?, private var isAppInBackground: Boolean, - private var onActivityResumed: (resumedActivity: Activity) -> Unit, - private var onActivityPaused: (pausedActivity: Activity) -> Unit, - private var onActivityStarted: (activity: Activity) -> Unit, - private var onActivityStopped: (activity: Activity) -> Unit, - private var onTrackVisitReady: (source: String?, requestUrl: String?) -> Unit, -) : Application.ActivityLifecycleCallbacks, LifecycleEventObserver { +) : Application.ActivityLifecycleCallbacks, LifecycleEventObserver, MindboxLog { + + internal interface Callbacks { + fun onActivityStarted(activity: Activity) {} + + fun onActivityPaused(activity: Activity) {} + + fun onActivityResumed(activity: Activity) {} + + fun onActivityStopped(activity: Activity) {} + + fun onTrackVisitReady(source: String?, requestUrl: String?) {} + } companion object { - private const val SCHEMA_HTTP = "http" - private const val SCHEMA_HTTPS = "https" + private const val TIMER_PERIOD = 1_200_000L + private const val MAX_INTENT_HASHES = 50 + + @Volatile + internal var instance: LifecycleManager? = null - private const val TIMER_PERIOD = 1200000L - private const val MAX_INTENT_HASHES_SIZE = 50 + internal val isRegister: Boolean get() = instance != null + + internal fun register(context: Context) { + val lifecycle = ProcessLifecycleOwner.get().lifecycle + val activity = context as? Activity + val application = context.applicationContext as? Application + val isForegrounded = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + + if (isForegrounded && activity == null) { + logE("Incorrect context type for calling init in this place") + } + if (isForegrounded || context !is Application) { + logW( + "We recommend to call Mindbox.init() synchronously from " + + "Application.onCreate. If you can't do so, don't forget to " + + "call Mindbox.initPushServices from Application.onCreate", + ) + } + + LifecycleManager( + currentActivityName = activity?.javaClass?.name, + currentIntent = activity?.intent, + isAppInBackground = !isForegrounded, + ).also { manager -> + application?.registerActivityLifecycleCallbacks(manager) + lifecycle.addObserver(manager) + instance = manager + } + } } - private var isIntentChanged = true - private var timer: Timer? = null + /** + * True when a foreground transition happened before [callbacks] was set — + * i.e. before [cloud.mindbox.mobile_sdk.Mindbox.init] was called. + */ + @Volatile + private var pendingVisit: Boolean = false + + @Volatile + var callbacks: Callbacks? = null + set(value) { + field = value + if (value != null && pendingVisit) { + pendingVisit = false + dispatchCurrentVisit(value) + } + } + + /** + * True by default — Activity.onResume() fires before the manager is registered + * when Mindbox.init() is called from Activity.onCreate(). + */ + var isCurrentActivityResumed: Boolean = true + private set + + private var intentChanged = true + private var keepaliveTimer: Timer? = null private val intentHashes = mutableListOf() + private var skipNextTrackVisit = false /** - * True by default. - * Has to be true because Activity.onResume() triggers before Lifecycle Manager is registered - * when Mindbox.init() was called in Activity.onCreate() - **/ - var isCurrentActivityResumed = true - private var skipSendingTrackVisit = false + * True when [onMovedToForeground] was called while [currentIntent] was still null — + * i.e. the app foregrounded before the first [onActivityStarted] callback arrived. + * + * This happens in Case 3: no [MindboxLifecycleInitializer], [Mindbox.init] called from + * [Application.onCreate]. [ProcessLifecycleOwnerInitializer] registers [LifecycleDispatcher] + * first, so the process-level ON_START fires *before* [LifecycleManager.onActivityStarted] + * updates [currentIntent]. The flag is cleared and the visit is dispatched inside + * [onActivityStarted] once the intent becomes available. + */ + private var foregroundedWithoutIntent = false - override fun onActivityCreated(activity: Activity, p1: Bundle?) { - } + override fun onActivityCreated(activity: Activity, p1: Bundle?) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) = Unit + + override fun onActivityDestroyed(activity: Activity) = Unit override fun onActivityStarted(activity: Activity): Unit = loggingRunCatching { mindboxLogI("onActivityStarted. activity: ${activity.javaClass.simpleName}") - onActivityStarted.invoke(activity) - val areActivitiesEqual = currentActivityName == activity.javaClass.name + callbacks?.onActivityStarted(activity) + + val sameActivity = currentActivityName == activity.javaClass.name val intent = activity.intent - isIntentChanged = if (currentIntent != intent) { - updateActivityParameters(activity) - intent?.hashCode()?.let(::updateHashesList) ?: true + intentChanged = if (currentIntent != intent) { + updateActivityState(activity) + intent?.hashCode()?.let(::isNewHash) ?: true } else { false } - if (isAppInBackground || !isIntentChanged) { + if (isAppInBackground || !intentChanged) { isAppInBackground = false + if (foregroundedWithoutIntent && intentChanged) { + foregroundedWithoutIntent = false + sendTrackVisit(intent ?: return@loggingRunCatching) + } return@loggingRunCatching } - sendTrackVisit(activity.intent, areActivitiesEqual) + sendTrackVisit(intent ?: return@loggingRunCatching, sameActivity) } override fun onActivityResumed(activity: Activity) { mindboxLogI("onActivityResumed. activity: ${activity.javaClass.simpleName}") isCurrentActivityResumed = true - onActivityResumed.invoke(activity) - isCurrentActivityResumed = true + callbacks?.onActivityResumed(activity) } override fun onActivityPaused(activity: Activity) { mindboxLogI("onActivityPaused. activity: ${activity.javaClass.simpleName}") isCurrentActivityResumed = false - onActivityPaused.invoke(activity) - isCurrentActivityResumed = false + callbacks?.onActivityPaused(activity) } override fun onActivityStopped(activity: Activity) { mindboxLogI("onActivityStopped. activity: ${activity.javaClass.simpleName}") if (currentIntent == null || currentActivityName == null) { - updateActivityParameters(activity) + updateActivityState(activity) } - onActivityStopped.invoke(activity) + callbacks?.onActivityStopped(activity) } - override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) { - } - - override fun onActivityDestroyed(activity: Activity) { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_STOP -> onMovedToBackground() + Lifecycle.Event.ON_START -> onMovedToForeground() + else -> Unit + } } fun isTrackVisitSent(): Boolean { currentIntent?.let { intent -> - if (updateHashesList(intent.hashCode())) { + if (isNewHash(intent.hashCode())) { sendTrackVisit(intent) } } return currentIntent != null } - fun wasReinitialized() { - skipSendingTrackVisit = true + /** + * Schedules a track-visit to be dispatched the next time [callbacks] is assigned. + * + * Call this before replacing [callbacks] via [cloud.mindbox.mobile_sdk.Mindbox.init] + * so the new endpoint receives a track-visit immediately upon reinitialisation. + * The backend uses this signal to learn the device is now active in the new environment. + */ + fun scheduleReinitTrackVisit() { + pendingVisit = true + mindboxLogI("Track visit scheduled for reinit") } - fun onNewIntent(newIntent: Intent?): Unit? = newIntent?.let { intent -> - if (intent.data != null || intent.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true) { - isIntentChanged = updateHashesList(intent.hashCode()) - sendTrackVisit(intent) - skipSendingTrackVisit = isAppInBackground - } + fun onNewIntent(newIntent: Intent?) { + val intent = newIntent ?: return + val hasDeepLink = intent.data != null + val isFromPush = intent.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true + if (!hasDeepLink && !isFromPush) return + + intentChanged = isNewHash(intent.hashCode()) + sendTrackVisit(intent) + skipNextTrackVisit = isAppInBackground } - private fun onAppMovedToBackground(): Unit = loggingRunCatching { + private fun onMovedToBackground(): Unit = loggingRunCatching { mindboxLogI("onAppMovedToBackground") isAppInBackground = true + pendingVisit = false + foregroundedWithoutIntent = false cancelKeepaliveTimer() } - private fun onAppMovedToForeground(): Unit = loggingRunCatching { + private fun onMovedToForeground(): Unit = loggingRunCatching { mindboxLogI("onAppMovedToForeground") - if (!skipSendingTrackVisit) { - currentIntent?.let(::sendTrackVisit) + if (skipNextTrackVisit) { + skipNextTrackVisit = false + return@loggingRunCatching + } + val intent = currentIntent + if (intent != null) { + sendTrackVisit(intent) } else { - skipSendingTrackVisit = false + foregroundedWithoutIntent = true + mindboxLogI("Track visit deferred — foregrounded before first activity") } } - private fun updateActivityParameters(activity: Activity): Unit = loggingRunCatching { + private fun updateActivityState(activity: Activity): Unit = loggingRunCatching { currentActivityName = activity.javaClass.name currentIntent = activity.intent } private fun sendTrackVisit( intent: Intent, - areActivitiesEqual: Boolean = true, + sameActivity: Boolean = true, ): Unit = loggingRunCatching { - val source = if (isIntentChanged) source(intent) else DIRECT - - if (areActivitiesEqual || source != DIRECT) { - val requestUrl = if (source == LINK) intent.data?.toString() else null - onTrackVisitReady.invoke(source, requestUrl) - startKeepaliveTimer() + val source = if (intentChanged) intentSource(intent) else DIRECT + if (!sameActivity && source == DIRECT) return@loggingRunCatching - mindboxLogI("Track visit event with source $source and url $requestUrl") + val cb = callbacks + if (cb == null) { + pendingVisit = true + mindboxLogI("Track visit pending (no callbacks yet)") + return@loggingRunCatching } + pendingVisit = false + val requestUrl = if (source == LINK) intent.data?.toString() else null + cb.onTrackVisitReady(source, requestUrl) + startKeepaliveTimer() + mindboxLogI("Track visit event with source $source and url $requestUrl") } - private fun source(intent: Intent?): String? = loggingRunCatching(defaultValue = null) { - when { - intent?.scheme == SCHEMA_HTTP || intent?.scheme == SCHEMA_HTTPS -> LINK - intent?.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true -> PUSH - else -> DIRECT - } + /** + * Derives source and URL from the already-stored [currentIntent]/[intentChanged] and + * dispatches the track-visit through [cb]. + * + * Called from the [callbacks] setter when [pendingVisit] is raised — the same pattern + * iOS uses in `MBSessionManager` when `initializationCompleted` fires while `isActive` is true. + */ + private fun dispatchCurrentVisit(cb: Callbacks): Unit = loggingRunCatching { + val intent = currentIntent ?: return@loggingRunCatching + val source = if (intentChanged) intentSource(intent) else DIRECT + val requestUrl = if (source == LINK) intent.data?.toString() else null + cb.onTrackVisitReady(source, requestUrl) + startKeepaliveTimer() + mindboxLogI("Track visit dispatched from pending state: source=$source url=$requestUrl") } - private fun updateHashesList(code: Int): Boolean = loggingRunCatching(defaultValue = true) { - if (!intentHashes.contains(code)) { - if (intentHashes.size >= MAX_INTENT_HASHES_SIZE) { - intentHashes.removeAt(0) - } - intentHashes.add(code) - true - } else { - false - } + private fun intentSource(intent: Intent): String = when { + intent.scheme == "http" || intent.scheme == "https" -> LINK + intent.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true -> PUSH + else -> DIRECT + } + + private fun isNewHash(hash: Int): Boolean = loggingRunCatching(defaultValue = true) { + if (intentHashes.contains(hash)) return@loggingRunCatching false + if (intentHashes.size >= MAX_INTENT_HASHES) intentHashes.removeAt(0) + intentHashes.add(hash) + true } private fun startKeepaliveTimer(): Unit = loggingRunCatching { cancelKeepaliveTimer() - timer = timer( + keepaliveTimer = timer( initialDelay = TIMER_PERIOD, period = TIMER_PERIOD, - action = { onTrackVisitReady.invoke(null, null) }, + action = { callbacks?.onTrackVisitReady(null, null) }, ) } private fun cancelKeepaliveTimer(): Unit = loggingRunCatching { - timer?.cancel() - timer = null - } - - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - when (event) { - Lifecycle.Event.ON_STOP -> onAppMovedToBackground() - Lifecycle.Event.ON_START -> onAppMovedToForeground() - else -> { - // do nothing - } - } + keepaliveTimer?.cancel() + keepaliveTimer = null } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt new file mode 100644 index 00000000..379f44ab --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt @@ -0,0 +1,31 @@ +package cloud.mindbox.mobile_sdk.managers + +import android.content.Context +import android.util.Log +import androidx.annotation.RestrictTo +import androidx.startup.Initializer +import cloud.mindbox.mobile_sdk.getCurrentProcessName +import cloud.mindbox.mobile_sdk.isMainProcess +import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl + +/** + * Registers [LifecycleManager] at application startup via androidx.startup so that lifecycle + * tracking begins before [cloud.mindbox.mobile_sdk.Mindbox.init] is called. + * + * Track-visit events are only dispatched after [cloud.mindbox.mobile_sdk.Mindbox.init] wires + * the [LifecycleManager.onTrackVisitReady] callback. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY) +public class MindboxLifecycleInitializer : Initializer { + + override fun create(context: Context) { + val currentProcessName = context.getCurrentProcessName() + if (!context.isMainProcess(currentProcessName)) return + + // Log before init mindbox + Log.i(MindboxLoggerImpl.TAG, "LifecycleInitializer: Register LifecycleManager in startup initializer") + LifecycleManager.register(context) + } + + override fun dependencies(): List>> = emptyList() +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt new file mode 100644 index 00000000..54367ecc --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt @@ -0,0 +1,1032 @@ +package cloud.mindbox.mobile_sdk.managers + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import cloud.mindbox.mobile_sdk.models.DIRECT +import cloud.mindbox.mobile_sdk.models.LINK +import cloud.mindbox.mobile_sdk.models.PUSH +import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager.IS_OPENED_FROM_PUSH_BUNDLE_KEY +import io.mockk.mockk +import io.mockk.junit4.MockKRule +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +internal class LifecycleManagerTest { + + @get:Rule + val mockkRule = MockKRule(this) + + private val trackVisitEvents = mutableListOf>() + private val startedActivities = mutableListOf() + private val resumedActivities = mutableListOf() + private val pausedActivities = mutableListOf() + private val stoppedActivities = mutableListOf() + + @Before + fun setUp() { + trackVisitEvents.clear() + startedActivities.clear() + resumedActivities.clear() + pausedActivities.clear() + stoppedActivities.clear() + } + + @After + fun tearDown() { + LifecycleManager.instance = null + } + + /** Manager with all callbacks wired to shared collections. */ + private fun createManager( + currentActivityName: String? = null, + currentIntent: Intent? = null, + isAppInBackground: Boolean = false, + ) = LifecycleManager( + currentActivityName = currentActivityName, + currentIntent = currentIntent, + isAppInBackground = isAppInBackground, + ).also { manager -> + manager.callbacks = object : LifecycleManager.Callbacks { + override fun onActivityStarted(activity: Activity) { + startedActivities += activity + } + + override fun onActivityResumed(activity: Activity) { + resumedActivities += activity + } + + override fun onActivityPaused(activity: Activity) { + pausedActivities += activity + } + + override fun onActivityStopped(activity: Activity) { + stoppedActivities += activity + } + + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + trackVisitEvents += source to requestUrl + } + } + } + + /** Manager with NO callbacks — simulates the pre-init state. */ + private fun createManagerNoCallbacks( + isAppInBackground: Boolean = true, + ) = LifecycleManager( + currentActivityName = null, + currentIntent = null, + isAppInBackground = isAppInBackground, + ) + + /** Attach a minimal track-visit listener after manager construction (simulates late init). */ + private fun listenTrackVisit(manager: LifecycleManager) { + manager.callbacks = object : LifecycleManager.Callbacks { + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + trackVisitEvents += source to requestUrl + } + } + } + + private fun buildActivityA(intent: Intent = Intent()): Activity = + Robolectric.buildActivity(LifecycleTestActivityA::class.java, intent).create().get() + + private fun buildActivityB(intent: Intent = Intent()): Activity = + Robolectric.buildActivity(LifecycleTestActivityB::class.java, intent).create().get() + + private fun mockOwner(): LifecycleOwner = mockk(relaxed = true) + + // region — null-safety: no crash before callbacks set + + @Test + fun `onActivityStarted does not crash when all callbacks are null`() { + createManagerNoCallbacks().onActivityStarted(mockk(relaxed = true)) + } + + @Test + fun `onActivityResumed does not crash when callback is null`() { + createManagerNoCallbacks().onActivityResumed(mockk(relaxed = true)) + } + + @Test + fun `onActivityPaused does not crash when callback is null`() { + createManagerNoCallbacks().onActivityPaused(mockk(relaxed = true)) + } + + @Test + fun `onActivityStopped does not crash when callback is null`() { + createManagerNoCallbacks().onActivityStopped(mockk(relaxed = true)) + } + + @Test + fun `onNewIntent does not crash when callbacks is null`() { + createManagerNoCallbacks().onNewIntent(Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com"))) + } + + // endregion + + // region — track visit NOT sent when callbacks is null + + @Test + fun `foreground transition does not send track visit when callbacks is null`() { + val manager = createManagerNoCallbacks() + manager.onActivityStarted(mockk(relaxed = true)) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_STOP) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertTrue(trackVisitEvents.isEmpty()) + } + + @Test + fun `onNewIntent does not send track visit when callbacks is null`() { + createManagerNoCallbacks().onNewIntent(Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com"))) + assertTrue(trackVisitEvents.isEmpty()) + } + + @Test + fun `onNewIntent with push intent does not send track visit when callbacks is null`() { + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + createManagerNoCallbacks().onNewIntent(intent) + assertTrue(trackVisitEvents.isEmpty()) + } + + // endregion + + // region — track visit sent after callbacks set (init flow) + + @Test + fun `foreground sends track visit after callbacks is set`() { + val manager = createManagerNoCallbacks() + manager.onActivityStarted(mockk(relaxed = true)) + listenTrackVisit(manager) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_STOP) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `onNewIntent sends LINK track visit for https deeplink after callbacks set`() { + val manager = createManagerNoCallbacks() + listenTrackVisit(manager) + val uri = Uri.parse("https://example.com/promo") + manager.onNewIntent(Intent(Intent.ACTION_VIEW, uri)) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(uri.toString(), trackVisitEvents[0].second) + } + + @Test + fun `onNewIntent sends PUSH track visit for push intent after callbacks set`() { + val manager = createManagerNoCallbacks() + listenTrackVisit(manager) + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + manager.onNewIntent(intent) + assertEquals(1, trackVisitEvents.size) + assertEquals(PUSH, trackVisitEvents[0].first) + } + + @Test + fun `repeated onNewIntent with same intent sends DIRECT on second call`() { + val manager = createManagerNoCallbacks() + listenTrackVisit(manager) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) + manager.onNewIntent(intent) + manager.onNewIntent(intent) + assertEquals(2, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(DIRECT, trackVisitEvents[1].first) + } + + // endregion + + // region — source detection via onActivityStarted + + @Test + fun `onActivityStarted sends DIRECT trackVisit for plain intent on same activity`() { + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(1, trackVisitEvents.size) + assertEquals(DIRECT to null, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted sends LINK trackVisit for HTTP deeplink intent`() { + val url = "http://example.com/promo" + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityA(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK to url, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted sends LINK trackVisit for HTTPS deeplink intent`() { + val url = "https://example.com/campaign" + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityA(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK to url, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted sends PUSH trackVisit for push-opened intent`() { + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityA(intent)) + assertEquals(1, trackVisitEvents.size) + assertEquals(PUSH to null, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted sends DIRECT when intentChanged is false on second call`() { + val url = "https://example.com" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + val activity = buildActivityA(intent) + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(activity) + trackVisitEvents.clear() + // hash already known → isTrackVisitSent returns true but sends nothing + assertTrue(manager.isTrackVisitSent()) + assertEquals(0, trackVisitEvents.size) + } + + // endregion + + // region — onActivityStarted send / no-send conditions + + @Test + fun `onActivityStarted does not send when same intent instance used again`() { + val intent = Intent().apply { putExtra("key", "value") } + val activity = buildActivityA(intent) + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(activity) + trackVisitEvents.clear() + manager.onActivityStarted(activity) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onActivityStarted does not send when app is in background`() { + val manager = createManager(isAppInBackground = true) + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onActivityStarted resets isAppInBackground after being called in background`() { + val manager = createManager(isAppInBackground = true) + manager.onActivityStarted(buildActivityA(Intent())) + trackVisitEvents.clear() + manager.onActivityStarted(buildActivityA(Intent().apply { putExtra("seq", 2) })) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `onActivityStarted does not send DIRECT trackVisit for different activity class`() { + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityB(Intent())) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onActivityStarted sends LINK trackVisit for different activity class with deeplink`() { + val url = "https://example.com" + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityB(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK to url, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted sends PUSH trackVisit for different activity class with push intent`() { + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(buildActivityB(intent)) + assertEquals(1, trackVisitEvents.size) + assertEquals(PUSH to null, trackVisitEvents[0]) + } + + @Test + fun `onActivityStarted invokes onActivityStarted callback`() { + val manager = createManager() + val activity = buildActivityA() + manager.onActivityStarted(activity) + assertEquals(1, startedActivities.size) + assertSame(activity, startedActivities[0]) + } + + // endregion + + // region — isCurrentActivityResumed + + @Test + fun `isCurrentActivityResumed is true by default`() { + assertTrue(createManager().isCurrentActivityResumed) + } + + @Test + fun `isCurrentActivityResumed is false after onActivityPaused`() { + val manager = createManager() + manager.onActivityPaused(mockk(relaxed = true)) + assertFalse(manager.isCurrentActivityResumed) + } + + @Test + fun `isCurrentActivityResumed is true after onActivityResumed`() { + val manager = createManager() + manager.onActivityPaused(mockk(relaxed = true)) + manager.onActivityResumed(mockk(relaxed = true)) + assertTrue(manager.isCurrentActivityResumed) + } + + @Test + fun `onActivityResumed sets isCurrentActivityResumed to true and invokes callback`() { + val manager = createManager() + val activity = buildActivityA() + manager.onActivityPaused(activity) + resumedActivities.clear() + manager.onActivityResumed(activity) + assertTrue(manager.isCurrentActivityResumed) + assertEquals(1, resumedActivities.size) + assertSame(activity, resumedActivities[0]) + } + + @Test + fun `onActivityPaused sets isCurrentActivityResumed to false and invokes callback`() { + val manager = createManager() + val activity = buildActivityA() + manager.onActivityPaused(activity) + assertFalse(manager.isCurrentActivityResumed) + assertEquals(1, pausedActivities.size) + assertSame(activity, pausedActivities[0]) + } + + @Test + fun `isCurrentActivityResumed toggles correctly across resume-pause cycles`() { + val manager = createManager() + val activity = buildActivityA() + manager.onActivityResumed(activity) + assertTrue(manager.isCurrentActivityResumed) + manager.onActivityPaused(activity) + assertFalse(manager.isCurrentActivityResumed) + manager.onActivityResumed(activity) + assertTrue(manager.isCurrentActivityResumed) + } + + // endregion + + // region — all activity callbacks invoked when assigned + + @Test + fun `all activity callbacks are invoked when assigned`() { + val started = mutableListOf() + val resumed = mutableListOf() + val paused = mutableListOf() + val stopped = mutableListOf() + + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = null, + isAppInBackground = false, + ) + manager.callbacks = object : LifecycleManager.Callbacks { + override fun onActivityStarted(activity: Activity) { + started += activity + } + + override fun onActivityResumed(activity: Activity) { + resumed += activity + } + + override fun onActivityPaused(activity: Activity) { + paused += activity + } + + override fun onActivityStopped(activity: Activity) { + stopped += activity + } + } + + val activity = mockk(relaxed = true) + manager.onActivityStarted(activity) + manager.onActivityResumed(activity) + manager.onActivityPaused(activity) + manager.onActivityStopped(activity) + + assertEquals(1, started.size) + assertEquals(1, resumed.size) + assertEquals(1, paused.size) + assertEquals(1, stopped.size) + assertSame(activity, started[0]) + assertSame(activity, resumed[0]) + assertSame(activity, paused[0]) + assertSame(activity, stopped[0]) + } + + // endregion + + // region — onActivityStopped + + @Test + fun `onActivityStopped invokes onActivityStopped callback`() { + val manager = createManager( + currentActivityName = LifecycleTestActivityA::class.java.name, + currentIntent = Intent(), + ) + val activity = buildActivityA() + manager.onActivityStopped(activity) + assertEquals(1, stoppedActivities.size) + assertSame(activity, stoppedActivities[0]) + } + + @Test + fun `onActivityStopped updates currentIntent when both fields are null`() { + val manager = createManager(currentActivityName = null, currentIntent = null) + val intent = Intent().apply { putExtra("stopped", true) } + val activity = buildActivityA(intent) + manager.onActivityStopped(activity) + // currentIntent is now set → isTrackVisitSent returns true + assertTrue(manager.isTrackVisitSent()) + } + + @Test + fun `onActivityStopped does not override currentIntent when both fields are already set`() { + val originalIntent = Intent().apply { putExtra("original", true) } + val manager = createManager( + currentActivityName = LifecycleTestActivityA::class.java.name, + currentIntent = originalIntent, + ) + manager.onActivityStopped(buildActivityB(Intent().apply { putExtra("other", true) })) + trackVisitEvents.clear() + // currentIntent unchanged → isTrackVisitSent still returns true (has an intent) + assertTrue(manager.isTrackVisitSent()) + } + + // endregion + + // region — app lifecycle (foreground / background) + + @Test + fun `ON_STOP sets app to background`() { + val manager = createManagerNoCallbacks() + listenTrackVisit(manager) + manager.onActivityStarted(mockk(relaxed = true)) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_STOP) + assertTrue(trackVisitEvents.isEmpty()) + } + + @Test + fun `ON_STOP sets app to background so next onActivityStarted skips trackVisit`() { + val manager = createManager() + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_STOP) + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `ON_START sends trackVisit with currentIntent`() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) + val manager = createManager(currentIntent = intent) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `ON_START does not send trackVisit when currentIntent is null`() { + val manager = createManager(currentIntent = null) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `ON_STOP then ON_START sends one trackVisit on return to foreground`() { + val intent = Intent() + val activity = buildActivityA(intent) + val owner = mockOwner() + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + manager.onActivityStarted(activity) + manager.onActivityResumed(activity) + manager.onActivityPaused(activity) + manager.onActivityStopped(activity) + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + trackVisitEvents.clear() + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `other lifecycle events do not send trackVisit`() { + val manager = createManager(currentIntent = Intent()) + val owner = mockOwner() + for (event in listOf( + Lifecycle.Event.ON_CREATE, + Lifecycle.Event.ON_RESUME, + Lifecycle.Event.ON_PAUSE, + Lifecycle.Event.ON_DESTROY, + )) { + manager.onStateChanged(owner, event) + } + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `keepalive timer fires onTrackVisitReady`() { + val manager = createManagerNoCallbacks() + listenTrackVisit(manager) + manager.onActivityStarted(mockk(relaxed = true)) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_STOP) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + // at least one track visit was sent (which in production also starts the timer) + assertEquals(1, trackVisitEvents.size) + } + + // endregion + + // region — scheduleReinitTrackVisit + // + // scheduleReinitTrackVisit() sets pendingVisit = true so the next callbacks assignment + // (via attachLifecycleCallbacks during Mindbox.init reinit) dispatches a track-visit + // immediately through the new endpoint. The backend uses this to learn the device is + // active in the new environment. + + @Test + fun `scheduleReinitTrackVisit dispatches visit immediately when callbacks are replaced`() { + // Simulate app already running with a known intent + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = Intent(), + isAppInBackground = false, + ) + // Reinit: schedule before replacing callbacks (mirrors setupLifecycleManager order) + manager.scheduleReinitTrackVisit() + // attachLifecycleCallbacks() replaces callbacks → pendingVisit = true → dispatch + listenTrackVisit(manager) + assertEquals("reinit must send exactly one visit via new callbacks", 1, trackVisitEvents.size) + } + + @Test + fun `scheduleReinitTrackVisit sends DIRECT source for plain intent`() { + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = Intent(), + isAppInBackground = false, + ) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + assertEquals(DIRECT, trackVisitEvents[0].first) + assertNull(trackVisitEvents[0].second) + } + + @Test + fun `scheduleReinitTrackVisit sends LINK source when current intent carries a deeplink`() { + val url = "https://example.com/promo" + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)), + isAppInBackground = false, + ) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(url, trackVisitEvents[0].second) + } + + @Test + fun `scheduleReinitTrackVisit does not send when currentIntent is null`() { + // e.g. very early reinit before any activity has started + val manager = createManagerNoCallbacks(isAppInBackground = false) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals("no visit when intent is still null", 0, trackVisitEvents.size) + } + + @Test + fun `scheduleReinitTrackVisit does not suppress following foreground visits`() { + val owner = mockOwner() + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = Intent(), + isAppInBackground = false, + ) + // Reinit dispatch + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + // Normal background + foreground must still produce a visit + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals("foreground after reinit must add exactly one more visit", 2, trackVisitEvents.size) + } + + @Test + fun `each scheduleReinitTrackVisit call triggers one visit per callbacks replacement`() { + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = Intent(), + isAppInBackground = false, + ) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + + // Second reinit + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals(2, trackVisitEvents.size) + } + + // endregion + + // region — isTrackVisitSent + + @Test + fun `isTrackVisitSent returns false when currentIntent is null`() { + val manager = createManager(currentIntent = null) + assertFalse(manager.isTrackVisitSent()) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `isTrackVisitSent returns true and sends trackVisit for new intent hash`() { + val manager = createManager(currentIntent = Intent()) + val result = manager.isTrackVisitSent() + assertTrue(result) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `isTrackVisitSent returns true but does not resend for already-known intent hash`() { + val manager = createManager(currentIntent = Intent()) + manager.isTrackVisitSent() + trackVisitEvents.clear() + val result = manager.isTrackVisitSent() + assertTrue(result) + assertEquals(0, trackVisitEvents.size) + } + + // endregion + + // region — onNewIntent + + @Test + fun `onNewIntent does nothing for null intent`() { + createManager().onNewIntent(null) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onNewIntent sends LINK trackVisit for deeplink intent`() { + val url = "https://example.com/promo" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + val manager = createManager() + manager.onNewIntent(intent) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK to url, trackVisitEvents[0]) + } + + @Test + fun `onNewIntent sends PUSH trackVisit for push intent`() { + val intent = Intent().apply { putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) } + val manager = createManager() + manager.onNewIntent(intent) + assertEquals(1, trackVisitEvents.size) + assertEquals(PUSH to null, trackVisitEvents[0]) + } + + @Test + fun `onNewIntent does not send trackVisit for plain intent without data or push key`() { + val manager = createManager() + manager.onNewIntent(Intent()) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onNewIntent sends DIRECT on second call with same intent`() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) + val manager = createManager() + manager.onNewIntent(intent) + trackVisitEvents.clear() + manager.onNewIntent(intent) + assertEquals(1, trackVisitEvents.size) + assertEquals(DIRECT, trackVisitEvents[0].first) + } + + @Test + fun `onNewIntent sets skipNextTrackVisit when app is in background`() { + val deeplink = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) + val manager = createManager(currentIntent = Intent(), isAppInBackground = true) + manager.onNewIntent(deeplink) + trackVisitEvents.clear() + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `onNewIntent when not in background does not set skipNextTrackVisit`() { + val deeplink = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")) + val manager = createManager(currentIntent = Intent(), isAppInBackground = false) + manager.onNewIntent(deeplink) + trackVisitEvents.clear() + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals(1, trackVisitEvents.size) + } + + // endregion + + // region — intent hash deduplication + + @Test + fun `different intents each trigger separate trackVisit`() { + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + for (i in 1..5) { + manager.onActivityStarted(buildActivityA(Intent().apply { putExtra("seq", i) })) + } + assertEquals(5, trackVisitEvents.size) + } + + @Test + fun `after MAX_INTENT_HASHES entries oldest hash is evicted allowing reuse`() { + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + val firstIntent = Intent().apply { putExtra("id", 0) } + manager.onActivityStarted(buildActivityA(firstIntent)) + for (i in 1..50) { + manager.onActivityStarted(buildActivityA(Intent().apply { putExtra("id", i) })) + } + trackVisitEvents.clear() + manager.onActivityStarted(buildActivityA(firstIntent)) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `reusing an intent whose hash is still in list does not send trackVisit`() { + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + val intent = Intent().apply { putExtra("id", 99) } + manager.onActivityStarted(buildActivityA(intent)) + trackVisitEvents.clear() + manager.onActivityStarted(buildActivityA(intent)) + assertEquals(0, trackVisitEvents.size) + } + + // endregion + + // region — callbacks set after foreground transition (late-init scenarios) + + @Test + fun `track visit dispatched when callbacks set after onMovedToForeground with null callbacks`() { + // Simulates Activity-init: LifecycleManager registered early, activity starts before init + val manager = createManagerNoCallbacks(isAppInBackground = true) + val owner = mockOwner() + // onActivityStarted clears background flag and records the intent + manager.onActivityStarted(buildActivityA(Intent())) + // ProcessLifecycle ON_START fires → onMovedToForeground, but callbacks are still null + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals("no track visit yet — callbacks not set", 0, trackVisitEvents.size) + + // Mindbox.init() sets callbacks + listenTrackVisit(manager) + + assertEquals("pending track visit must be dispatched immediately on callbacks set", 1, trackVisitEvents.size) + } + + @Test + fun `no extra track visit when callbacks set while still in background`() { + // Simulates Application.onCreate() init: callbacks set before any activity starts + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) + // No activity has started yet — no track visit should be sent + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `pending is cleared on background so next foreground sends exactly one track visit`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + val owner = mockOwner() + manager.onActivityStarted(buildActivityA(Intent())) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + // Goes back to background before callbacks are set + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + listenTrackVisit(manager) + // Pending was cleared on background → no dispatch on callbacks set + assertEquals(0, trackVisitEvents.size) + // Normal foreground cycle now sends exactly one track visit + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `deeplink source derived from stored intent state on late callbacks set`() { + // Source (LINK) and URL are re-computed from currentIntent/intentChanged at dispatch time, + // not stored as parameters — same as iOS deriving visit info from stored state. + val manager = createManagerNoCallbacks(isAppInBackground = true) + val owner = mockOwner() + val url = "https://example.com/promo" + manager.onActivityStarted(buildActivityA(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + // ON_START fires with callbacks null — sets pendingVisit=true, stores nothing else + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + // callbacks setter fires dispatchCurrentVisit → derives LINK from currentIntent + listenTrackVisit(manager) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(url, trackVisitEvents[0].second) + } + + // endregion + + // region — Case 3: foreground fires before first activity (foregroundedWithoutIntent flag) + // + // Scenario: MindboxLifecycleInitializer did NOT run. Mindbox.init() is called from + // Application.onCreate(), so callbacks are set before any activity starts. + // Because ProcessLifecycleOwnerInitializer registered LifecycleDispatcher first, the + // process-level ON_START event fires *before* LifecycleManager.onActivityStarted. + // At that moment currentIntent is null, so the visit must be deferred until + // onActivityStarted provides the intent. + + @Test + fun `track visit sent when ON_START fires before first onActivityStarted`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) // callbacks set in Application.onCreate, before any activity + + // ON_START fires first (currentIntent still null) — no visit yet + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + assertEquals("no track visit yet — currentIntent is null", 0, trackVisitEvents.size) + + // onActivityStarted fires after, supplying the intent + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals("track visit must be dispatched once intent arrives", 1, trackVisitEvents.size) + } + + @Test + fun `DIRECT source sent in Case 3 for plain cold-start intent`() { + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(1, trackVisitEvents.size) + assertEquals(DIRECT, trackVisitEvents[0].first) + assertNull(trackVisitEvents[0].second) + } + + @Test + fun `LINK source sent in Case 3 when first activity carries a deeplink intent`() { + val url = "https://example.com/promo" + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) + manager.onStateChanged(mockOwner(), Lifecycle.Event.ON_START) + manager.onActivityStarted(buildActivityA(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK, trackVisitEvents[0].first) + assertEquals(url, trackVisitEvents[0].second) + } + + @Test + fun `foregroundedWithoutIntent is cleared on background so stale flag does not fire later`() { + val owner = mockOwner() + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + // Activity starts after background — flag is gone, no track visit from Case 3 path + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `Case 3 full cycle then background-foreground sends exactly two track visits total`() { + val owner = mockOwner() + val manager = createManagerNoCallbacks(isAppInBackground = true) + listenTrackVisit(manager) + + // Case 3 first foreground + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals(1, trackVisitEvents.size) + + // User backgrounds and returns + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + assertEquals("second foreground must add exactly one more visit", 2, trackVisitEvents.size) + } + + @Test + fun `scheduleReinitTrackVisit in Case 3 still sends deferred visit when activity provides intent`() { + // Reinit happens in Case 3 (no initializer + Application.onCreate). + // pendingVisit = true from scheduleReinitTrackVisit; callbacks replaced immediately after. + // dispatchCurrentVisit returns early (currentIntent == null). + // foregroundedWithoutIntent path then delivers the visit once the activity starts. + val owner = mockOwner() + val manager = createManagerNoCallbacks(isAppInBackground = true) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals("no visit yet — intent is null at dispatch time", 0, trackVisitEvents.size) + // ON_START fires before activity (Case 3) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + // Activity starts, providing the intent + manager.onActivityStarted(buildActivityA(Intent())) + assertEquals("reinit must not suppress the deferred Case 3 visit", 1, trackVisitEvents.size) + } + + // endregion + + // region — initialization order + + @Test + fun `manager with null currentActivityName does not send DIRECT for first activity start`() { + val manager = createManager(currentActivityName = null, currentIntent = null) + manager.onActivityStarted(buildActivityA(Intent())) + // areActivitiesEqual = (null == ActivityA.name) = false, source = DIRECT → no send + assertEquals(0, trackVisitEvents.size) + } + + @Test + fun `manager with null currentActivityName sends non-DIRECT trackVisit for first activity start`() { + val url = "https://example.com" + val manager = createManager(currentActivityName = null, currentIntent = null) + manager.onActivityStarted(buildActivityA(Intent(Intent.ACTION_VIEW, Uri.parse(url)))) + // areActivitiesEqual = false, source = LINK → sends + assertEquals(1, trackVisitEvents.size) + assertEquals(LINK to url, trackVisitEvents[0]) + } + + @Test + fun `full session lifecycle produces exactly two trackVisit events`() { + val intent = Intent() + val activity = buildActivityA(intent) + val owner = mockOwner() + val manager = createManager(currentActivityName = LifecycleTestActivityA::class.java.name) + // Launch + manager.onActivityStarted(activity) + manager.onActivityResumed(activity) + // User presses home + manager.onActivityPaused(activity) + manager.onActivityStopped(activity) + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + // User returns + manager.onStateChanged(owner, Lifecycle.Event.ON_START) + manager.onActivityStarted(activity) + manager.onActivityResumed(activity) + assertEquals(2, trackVisitEvents.size) + } + + @Test + fun `scheduleReinitTrackVisit while backgrounded sends visit on foreground and does not block subsequent visits`() { + // Typical reinit-while-backgrounded scenario: + // 1. Initial launch → visit #1 + // 2. User backgrounds the app + // 3. Mindbox.init() called again (reinit) → scheduleReinitTrackVisit + callbacks replaced + // → visit #2 dispatched immediately via dispatchCurrentVisit (new endpoint) + // 4. User returns to foreground → visit #3 + // 5. User backgrounds and foregrounds again → visit #4 + val intent = Intent() + val activity = buildActivityA(intent) + val owner = mockOwner() + val manager = LifecycleManager( + currentActivityName = LifecycleTestActivityA::class.java.name, + currentIntent = null, + isAppInBackground = false, + ) + listenTrackVisit(manager) // first init callbacks + + // Initial activity launch + manager.onActivityStarted(activity) // visit #1 + manager.onActivityResumed(activity) + manager.onActivityPaused(activity) + manager.onActivityStopped(activity) + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + assertEquals(1, trackVisitEvents.size) + + // Reinit while backgrounded: schedule then replace callbacks (mirrors Mindbox.init flow) + manager.scheduleReinitTrackVisit() + listenTrackVisit(manager) + assertEquals("reinit dispatches visit immediately through new callbacks", 2, trackVisitEvents.size) + + // User returns + manager.onStateChanged(owner, Lifecycle.Event.ON_START) // visit #3 + assertEquals(3, trackVisitEvents.size) + + // Another background + foreground cycle + manager.onStateChanged(owner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(owner, Lifecycle.Event.ON_START) // visit #4 + assertEquals(4, trackVisitEvents.size) + } + + // endregion +} + +private fun assertSame(expected: Any, actual: Any) { + assertTrue("Expected same instance", expected === actual) +} + +internal class LifecycleTestActivityA : Activity() + +internal class LifecycleTestActivityB : Activity() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt new file mode 100644 index 00000000..0e633a38 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt @@ -0,0 +1,132 @@ +package cloud.mindbox.mobile_sdk.managers + +import android.app.Application +import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.getCurrentProcessName +import cloud.mindbox.mobile_sdk.isMainProcess +import io.mockk.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class MindboxLifecycleInitializerTest { + + private lateinit var context: Application + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + mockkStatic("cloud.mindbox.mobile_sdk.ExtensionsKt") + } + + @After + fun tearDown() { + LifecycleManager.instance = null + unmockkAll() + } + + @Test + fun `instance is null before create is called`() { + assertNull(LifecycleManager.instance) + } + + @Test + fun `creates and stores LifecycleManager instance in main process`() { + every { any().getCurrentProcessName() } returns context.packageName + every { any().isMainProcess(context.packageName) } returns true + + MindboxLifecycleInitializer().create(context) + + assertNotNull(LifecycleManager.instance) + } + + @Test + fun `skips registration in non-main process`() { + val nonMainProcessName = "${context.packageName}:push" + every { any().getCurrentProcessName() } returns nonMainProcessName + every { any().isMainProcess(nonMainProcessName) } returns false + + MindboxLifecycleInitializer().create(context) + + assertNull( + "LifecycleManager must not be created outside the main process", + LifecycleManager.instance, + ) + } + + @Test + fun `calling create twice creates a new instance each time`() { + every { any().getCurrentProcessName() } returns context.packageName + every { any().isMainProcess(any()) } returns true + + MindboxLifecycleInitializer().create(context) + val firstInstance = LifecycleManager.instance + + MindboxLifecycleInitializer().create(context) + + assertNotNull(LifecycleManager.instance) + assertNotSame( + "second create must produce a new LifecycleManager instance", + firstInstance, + LifecycleManager.instance, + ) + } + + @Test + fun `isAppInBackground is true when ProcessLifecycleOwner is below STARTED`() { + every { any().getCurrentProcessName() } returns context.packageName + every { any().isMainProcess(any()) } returns true + + // At test time ProcessLifecycleOwner has not been started by any Activity + val stateBefore = ProcessLifecycleOwner.get().lifecycle.currentState + val expectedBackground = !stateBefore.isAtLeast(Lifecycle.State.STARTED) + + MindboxLifecycleInitializer().create(context) + + // Verify by attempting a foreground track visit: if manager was created with + // isAppInBackground=true, onActivityStarted will just clear the flag, not send a visit + val trackVisitEvents = mutableListOf>() + val manager = LifecycleManager.instance!! + manager.callbacks = object : LifecycleManager.Callbacks { + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + trackVisitEvents.add(source to requestUrl) + } + } + + manager.onActivityStarted(mockk(relaxed = true)) + + if (expectedBackground) { + assertTrue( + "Track visit must not be sent in first onActivityStarted when isAppInBackground was true", + trackVisitEvents.isEmpty(), + ) + } + } + + @Test + fun `non-main process skips creation regardless of process name format`() { + val processNames = listOf( + "${context.packageName}:firebase", + "${context.packageName}:push", + "com.yandex.metrica", + ":remote", + ) + + processNames.forEach { name -> + LifecycleManager.instance = null + every { any().getCurrentProcessName() } returns name + every { any().isMainProcess(name) } returns false + + MindboxLifecycleInitializer().create(context) + + assertNull("Process '$name' must not create a LifecycleManager", LifecycleManager.instance) + } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxSetupLifecycleManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxSetupLifecycleManagerTest.kt new file mode 100644 index 00000000..017aa025 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxSetupLifecycleManagerTest.kt @@ -0,0 +1,140 @@ +package cloud.mindbox.mobile_sdk.managers + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.Mindbox +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Tests for [Mindbox.setupLifecycleManager] and [Mindbox.attachLifecycleCallbacks]. + * + * Both methods are private, so they are invoked via reflection. The observable side-effects + * (changes to [LifecycleManager.instance] and its [LifecycleManager.callbacks] field) are used + * to assert correctness. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +internal class MindboxSetupLifecycleManagerTest { + + private val context: Application = ApplicationProvider.getApplicationContext() + + @After + fun tearDown() { + LifecycleManager.instance = null + setFirstInitCall(true) + unmockkAll() + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private fun setFirstInitCall(value: Boolean) { + val field = Mindbox::class.java.getDeclaredField("firstInitCall") + field.isAccessible = true + (field.get(Mindbox) as AtomicBoolean).set(value) + } + + private fun callSetupLifecycleManager(ctx: Context = context) { + val method = Mindbox::class.java + .getDeclaredMethod("setupLifecycleManager", Context::class.java) + method.isAccessible = true + method.invoke(Mindbox, ctx) + } + + private fun callAttachLifecycleCallbacks() { + val method = Mindbox::class.java.getDeclaredMethod("attachLifecycleCallbacks") + method.isAccessible = true + method.invoke(Mindbox) + } + + // ── setupLifecycleManager ───────────────────────────────────────────────── + + @Test + fun `register is called as fallback when startup initializer did not run`() { + assertNull(LifecycleManager.instance) + + callSetupLifecycleManager() + + assertNotNull(LifecycleManager.instance) + } + + @Test + fun `existing instance is kept when startup initializer already ran`() { + val existing = LifecycleManager(null, null, isAppInBackground = true) + LifecycleManager.instance = existing + + callSetupLifecycleManager() + + assertSame( + "register must not be called when already registered", + existing, + LifecycleManager.instance, + ) + } + + @Test + fun `scheduleReinitTrackVisit is called when already registered and it is not the first init`() { + val spy = spyk(LifecycleManager(null, null, isAppInBackground = true)) + LifecycleManager.instance = spy + setFirstInitCall(false) + + callSetupLifecycleManager() + + verify(exactly = 1) { spy.scheduleReinitTrackVisit() } + } + + @Test + fun `scheduleReinitTrackVisit is not called on the first init even when already registered`() { + val spy = spyk(LifecycleManager(null, null, isAppInBackground = true)) + LifecycleManager.instance = spy + // firstInitCall is true by default — no override needed + + callSetupLifecycleManager() + + verify(exactly = 0) { spy.scheduleReinitTrackVisit() } + } + + // ── attachLifecycleCallbacks ────────────────────────────────────────────── + + @Test + fun `attachLifecycleCallbacks sets callbacks when instance exists`() { + LifecycleManager.instance = LifecycleManager(null, null, isAppInBackground = true) + assertNull(LifecycleManager.instance!!.callbacks) + + callAttachLifecycleCallbacks() + + assertNotNull(LifecycleManager.instance!!.callbacks) + } + + @Test + fun `attachLifecycleCallbacks is a no-op when instance is null`() { + assertNull(LifecycleManager.instance) + + callAttachLifecycleCallbacks() // must not throw + } + + @Test + fun `attachLifecycleCallbacks replaces callbacks on each call`() { + val manager = LifecycleManager(null, null, isAppInBackground = true) + LifecycleManager.instance = manager + + callAttachLifecycleCallbacks() + val first = manager.callbacks + + callAttachLifecycleCallbacks() + val second = manager.callbacks + + assertNotNull(first) + assertNotNull(second) + assertNotSame("each init call must install a fresh Callbacks instance", first, second) + } +} From ab5f880a201e056319d377018e9c7da485ad2852 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 18 May 2026 18:32:21 +0300 Subject: [PATCH 02/37] MOBILE-78: Fix logs --- sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt | 9 +++------ .../mindbox/mobile_sdk/managers/LifecycleManager.kt | 3 +++ .../mobile_sdk/managers/MindboxLifecycleInitializer.kt | 6 ++---- .../managers/MindboxLifecycleInitializerTest.kt | 6 +++--- 4 files changed, 11 insertions(+), 13 deletions(-) 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 a249959e..12f9f2d7 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -873,12 +873,9 @@ public object Mindbox : MindboxLog { * @param intent new intent for activity, which was received in [Activity.onNewIntent] method */ public fun onNewIntent(intent: Intent?): Unit = LoggingExceptionHandler.runCatching { - MindboxLoggerImpl.d(this, "onNewIntent. intent: $intent") - if (lifecycleManager != null) { - lifecycleManager?.onNewIntent(intent) - } else { - MindboxLoggerImpl.d(this, "onNewIntent. LifecycleManager is not initialized. Skipping.") - } + mindboxLogI("onNewIntent. intent: $intent") + lifecycleManager?.onNewIntent(intent) + ?: mindboxLogI("onNewIntent. LifecycleManager is not initialized. Skipping.") } /** diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt index df8dc7b1..81fa0ab9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt @@ -50,6 +50,8 @@ internal class LifecycleManager internal constructor( internal val isRegister: Boolean get() = instance != null internal fun register(context: Context) { + if (instance != null) return + val lifecycle = ProcessLifecycleOwner.get().lifecycle val activity = context as? Activity val application = context.applicationContext as? Application @@ -117,6 +119,7 @@ internal class LifecycleManager internal constructor( * updates [currentIntent]. The flag is cleared and the visit is dispatched inside * [onActivityStarted] once the intent becomes available. */ + @Volatile private var foregroundedWithoutIntent = false override fun onActivityCreated(activity: Activity, p1: Bundle?) = Unit diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt index 379f44ab..61ddcca1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt @@ -1,12 +1,11 @@ package cloud.mindbox.mobile_sdk.managers import android.content.Context -import android.util.Log import androidx.annotation.RestrictTo import androidx.startup.Initializer import cloud.mindbox.mobile_sdk.getCurrentProcessName import cloud.mindbox.mobile_sdk.isMainProcess -import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl +import cloud.mindbox.mobile_sdk.logger.mindboxLogI /** * Registers [LifecycleManager] at application startup via androidx.startup so that lifecycle @@ -22,8 +21,7 @@ public class MindboxLifecycleInitializer : Initializer { val currentProcessName = context.getCurrentProcessName() if (!context.isMainProcess(currentProcessName)) return - // Log before init mindbox - Log.i(MindboxLoggerImpl.TAG, "LifecycleInitializer: Register LifecycleManager in startup initializer") + mindboxLogI("LifecycleInitializer: Register LifecycleManager in startup initializer") LifecycleManager.register(context) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt index 0e633a38..d2e54385 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt @@ -62,7 +62,7 @@ internal class MindboxLifecycleInitializerTest { } @Test - fun `calling create twice creates a new instance each time`() { + fun `calling create twice keeps the first instance`() { every { any().getCurrentProcessName() } returns context.packageName every { any().isMainProcess(any()) } returns true @@ -72,8 +72,8 @@ internal class MindboxLifecycleInitializerTest { MindboxLifecycleInitializer().create(context) assertNotNull(LifecycleManager.instance) - assertNotSame( - "second create must produce a new LifecycleManager instance", + assertSame( + "second create must be a no-op — the existing instance must be kept to avoid a leaked observer", firstInstance, LifecycleManager.instance, ) From e0690a1be9aca6f42ea1581306ad9010ba403f29 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 19 May 2026 17:02:06 +0300 Subject: [PATCH 03/37] MOBILE-78: Add sync for track-visits --- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 1 + .../mobile_sdk/managers/LifecycleManager.kt | 23 ++++--- .../MindboxLifecycleInitializerTest.kt | 68 +++++++++++++++---- 3 files changed, 69 insertions(+), 23 deletions(-) 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 12f9f2d7..88cde261 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -679,6 +679,7 @@ public object Mindbox : MindboxLog { override fun onTrackVisitReady(source: String?, requestUrl: String?) { sessionStorageManager.hasSessionExpired() eventScope.launch { + InitializeLock.await(InitializeLock.State.SAVE_MINDBOX_CONFIG) sendTrackVisitEvent( MindboxDI.appModule.appContext, source, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt index 81fa0ab9..b09bcf38 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt @@ -68,14 +68,21 @@ internal class LifecycleManager internal constructor( ) } - LifecycleManager( - currentActivityName = activity?.javaClass?.name, - currentIntent = activity?.intent, - isAppInBackground = !isForegrounded, - ).also { manager -> - application?.registerActivityLifecycleCallbacks(manager) - lifecycle.addObserver(manager) - instance = manager + // Double-checked locking: the fast path above filters the common case cheaply; + // the synchronized block below prevents two racing threads from both creating + // a manager and registering it twice as an observer. + synchronized(LifecycleManager::class.java) { + if (instance != null) return + + LifecycleManager( + currentActivityName = activity?.javaClass?.name, + currentIntent = activity?.intent, + isAppInBackground = !isForegrounded, + ).also { manager -> + application?.registerActivityLifecycleCallbacks(manager) + lifecycle.addObserver(manager) + instance = manager + } } } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt index d2e54385..f36c32e4 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt @@ -1,12 +1,16 @@ package cloud.mindbox.mobile_sdk.managers +import android.app.Activity import android.app.Application import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import androidx.test.core.app.ApplicationProvider import cloud.mindbox.mobile_sdk.getCurrentProcessName import cloud.mindbox.mobile_sdk.isMainProcess +import cloud.mindbox.mobile_sdk.models.LINK import io.mockk.* import org.junit.After import org.junit.Assert.* @@ -80,34 +84,68 @@ internal class MindboxLifecycleInitializerTest { } @Test - fun `isAppInBackground is true when ProcessLifecycleOwner is below STARTED`() { + fun `isAppInBackground true - suppresses track visit on first onActivityStarted`() { every { any().getCurrentProcessName() } returns context.packageName every { any().isMainProcess(any()) } returns true - // At test time ProcessLifecycleOwner has not been started by any Activity - val stateBefore = ProcessLifecycleOwner.get().lifecycle.currentState - val expectedBackground = !stateBefore.isAtLeast(Lifecycle.State.STARTED) + // Validate Robolectric environment assumption before trusting the assertion below. + val state = ProcessLifecycleOwner.get().lifecycle.currentState + assertFalse( + "Test requires ProcessLifecycleOwner below STARTED; got $state", + state.isAtLeast(Lifecycle.State.STARTED), + ) MindboxLifecycleInitializer().create(context) - // Verify by attempting a foreground track visit: if manager was created with - // isAppInBackground=true, onActivityStarted will just clear the flag, not send a visit - val trackVisitEvents = mutableListOf>() - val manager = LifecycleManager.instance!! + val manager = checkNotNull(LifecycleManager.instance) + val events = mutableListOf>() manager.callbacks = object : LifecycleManager.Callbacks { override fun onTrackVisitReady(source: String?, requestUrl: String?) { - trackVisitEvents.add(source to requestUrl) + events.add(source to requestUrl) } } - manager.onActivityStarted(mockk(relaxed = true)) + // https intent → source = LINK, which bypasses the `!sameActivity && DIRECT` guard, so + // a visit would be dispatched if isAppInBackground were false. + val deepLinkIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/promo")) + val activity = mockk(relaxed = true) { + every { this@mockk.intent } returns deepLinkIntent + } + + manager.onActivityStarted(activity) - if (expectedBackground) { - assertTrue( - "Track visit must not be sent in first onActivityStarted when isAppInBackground was true", - trackVisitEvents.isEmpty(), - ) + assertTrue( + "Track visit must NOT be dispatched on first onActivityStarted when the manager " + + "was created while the app was in the background (isAppInBackground = true)", + events.isEmpty(), + ) + } + + @Test + fun `isAppInBackground false - dispatches LINK track visit on onActivityStarted with https intent`() { + val deepLinkUrl = "https://example.com/promo" + val deepLinkIntent = Intent(Intent.ACTION_VIEW, Uri.parse(deepLinkUrl)) + + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = null, + isAppInBackground = false, + ) + val events = mutableListOf>() + manager.callbacks = object : LifecycleManager.Callbacks { + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + events.add(source to requestUrl) + } } + val activity = mockk(relaxed = true) { + every { this@mockk.intent } returns deepLinkIntent + } + + manager.onActivityStarted(activity) + + assertEquals("Exactly one track visit must be dispatched", 1, events.size) + assertEquals("Source must be LINK for an https intent", LINK, events[0].first) + assertEquals("URL must equal the deep-link URI", deepLinkUrl, events[0].second) } @Test From ccc04b578802f264cd8d06f957b60502a8570101 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 20 May 2026 17:55:12 +0300 Subject: [PATCH 04/37] MOBILE-78: Fix track-visit direct --- .../mobile_sdk/managers/LifecycleManager.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt index b09bcf38..d8ab6c67 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt @@ -152,12 +152,12 @@ internal class LifecycleManager internal constructor( isAppInBackground = false if (foregroundedWithoutIntent && intentChanged) { foregroundedWithoutIntent = false - sendTrackVisit(intent ?: return@loggingRunCatching) + sendTrackVisit(intent) } return@loggingRunCatching } - sendTrackVisit(intent ?: return@loggingRunCatching, sameActivity) + sendTrackVisit(intent, sameActivity) } override fun onActivityResumed(activity: Activity) { @@ -249,7 +249,7 @@ internal class LifecycleManager internal constructor( } private fun sendTrackVisit( - intent: Intent, + intent: Intent?, sameActivity: Boolean = true, ): Unit = loggingRunCatching { val source = if (intentChanged) intentSource(intent) else DIRECT @@ -262,7 +262,7 @@ internal class LifecycleManager internal constructor( return@loggingRunCatching } pendingVisit = false - val requestUrl = if (source == LINK) intent.data?.toString() else null + val requestUrl = if (source == LINK) intent?.data?.toString() else null cb.onTrackVisitReady(source, requestUrl) startKeepaliveTimer() mindboxLogI("Track visit event with source $source and url $requestUrl") @@ -284,9 +284,9 @@ internal class LifecycleManager internal constructor( mindboxLogI("Track visit dispatched from pending state: source=$source url=$requestUrl") } - private fun intentSource(intent: Intent): String = when { - intent.scheme == "http" || intent.scheme == "https" -> LINK - intent.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true -> PUSH + private fun intentSource(intent: Intent?): String = when { + intent?.scheme == "http" || intent?.scheme == "https" -> LINK + intent?.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true -> PUSH else -> DIRECT } From 471de2e83f6d723e036ffb4eeb0aa24f7788e9ef Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 25 May 2026 18:24:16 +0300 Subject: [PATCH 05/37] Add loggable property --- .../cloud/mindbox/mobile_sdk/logger/Level.kt | 2 +- .../mobile_sdk/logger/MindboxLoggerImpl.kt | 27 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt index c03e3f41..af96c4e5 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt @@ -1,6 +1,6 @@ package cloud.mindbox.mobile_sdk.logger -public enum class Level(public var value: Int) { +public enum class Level(public val value: Int) { VERBOSE(0), DEBUG(1), diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt index a5133518..ab984a46 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt @@ -50,12 +50,25 @@ internal object MindboxLoggerImpl : MindboxLogger { internal var level: Level = DEFAULT_LOG_LEVEL /** - * All the methods below should be used only after Mindbox.initComponents method was called + * Returns [Level.DEBUG] if `adb shell setprop log.tag.Mindbox DEBUG` (or VERBOSE) is active, + * [Level.NONE] otherwise. Any other setprop value is treated as "not set". + * + * When [Level.DEBUG] is returned it overrides the programmatic [level], enabling all log + * output regardless of what [level] is currently configured to. + * + * `Log.isLoggable(TAG, Log.DEBUG)` returns `true` for both VERBOSE and DEBUG setprop values + * because VERBOSE has a lower priority than DEBUG in Android's priority scale. + * The property is read on every call, so changes take effect immediately without app restart. */ + private fun setPropLevel(): Level = + if (Log.isLoggable(TAG, Log.DEBUG)) Level.DEBUG else Level.NONE + /** + * All the methods below should be used only after Mindbox.initComponents method was called + */ override fun i(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.INFO.value) { + if (level <= Level.INFO || setPropLevel() <= Level.INFO) { Log.i(TAG, logMessage) } saveLog(logMessage) @@ -63,7 +76,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun d(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.DEBUG.value) { + if (level <= Level.DEBUG || setPropLevel() <= Level.DEBUG) { Log.d(TAG, logMessage) } saveLog(logMessage) @@ -71,7 +84,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun e(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.ERROR.value) { + if (level <= Level.ERROR || setPropLevel() <= Level.ERROR) { Log.e(TAG, logMessage) } saveLog(logMessage) @@ -79,7 +92,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun e(parent: Any, message: String, exception: Throwable) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.ERROR.value) { + if (level <= Level.ERROR || setPropLevel() <= Level.ERROR) { Log.e(TAG, logMessage, exception) } saveLog(logMessage + exception.stackTraceToString()) @@ -87,7 +100,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun w(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.WARN.value) { + if (level <= Level.WARN || setPropLevel() <= Level.WARN) { Log.w(TAG, logMessage) } saveLog(logMessage) @@ -95,7 +108,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun w(parent: Any, message: String, exception: Throwable) { val logMessage = buildMessage(parent, message) - if (level.value <= Level.WARN.value) { + if (level <= Level.WARN || setPropLevel() <= Level.WARN) { Log.w(TAG, logMessage, exception) } saveLog(logMessage + exception.stackTraceToString()) From 6b5c6bcdfda63525c4a095820308176f99e55366 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 26 May 2026 11:04:53 +0300 Subject: [PATCH 06/37] Fix log Level compare --- modulesCommon.gradle | 4 ++++ sdk/build.gradle | 1 - .../cloud/mindbox/mobile_sdk/logger/Level.kt | 4 ++++ .../mobile_sdk/logger/MindboxLoggerImpl.kt | 21 ++++++++++++------- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/modulesCommon.gradle b/modulesCommon.gradle index e1b436a4..3aeb5ac7 100644 --- a/modulesCommon.gradle +++ b/modulesCommon.gradle @@ -34,6 +34,10 @@ android { } } + testOptions { + unitTests.returnDefaultValues = true + } + compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 diff --git a/sdk/build.gradle b/sdk/build.gradle index 79789515..326c6663 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -36,7 +36,6 @@ android { testOptions { unitTests.includeAndroidResources = true - unitTests.returnDefaultValues = true } tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { kotlinOptions { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt index af96c4e5..592bcd0b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt @@ -9,3 +9,7 @@ public enum class Level(public val value: Int) { ERROR(4), NONE(5) } + +internal infix fun Level.isAtMost(other: Level): Boolean = value <= other.value + +internal infix fun Level.isAtLeast(other: Level): Boolean = value >= other.value diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt index ab984a46..bfc825ff 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/MindboxLoggerImpl.kt @@ -58,7 +58,14 @@ internal object MindboxLoggerImpl : MindboxLogger { * * `Log.isLoggable(TAG, Log.DEBUG)` returns `true` for both VERBOSE and DEBUG setprop values * because VERBOSE has a lower priority than DEBUG in Android's priority scale. - * The property is read on every call, so changes take effect immediately without app restart. + * The property is consulted when execution reaches the setprop override check, so changes + * take effect immediately without app restart for calls that evaluate this branch. + * + * By design, only DEBUG and VERBOSE are supported as override values. WARN and ERROR could + * technically be detected (they make `isLoggable(INFO)` return `false`, unlike the default), + * but using setprop to make logging *more restrictive* than the SDK default serves no practical + * debugging purpose. INFO is indistinguishable from "no setprop" and is also intentionally + * ignored. Use [Mindbox.setLogLevel] for fine-grained programmatic control. */ private fun setPropLevel(): Level = if (Log.isLoggable(TAG, Log.DEBUG)) Level.DEBUG else Level.NONE @@ -68,7 +75,7 @@ internal object MindboxLoggerImpl : MindboxLogger { */ override fun i(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level <= Level.INFO || setPropLevel() <= Level.INFO) { + if (level isAtMost Level.INFO || setPropLevel() isAtMost Level.INFO) { Log.i(TAG, logMessage) } saveLog(logMessage) @@ -76,7 +83,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun d(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level <= Level.DEBUG || setPropLevel() <= Level.DEBUG) { + if (level isAtMost Level.DEBUG || setPropLevel() isAtMost Level.DEBUG) { Log.d(TAG, logMessage) } saveLog(logMessage) @@ -84,7 +91,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun e(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level <= Level.ERROR || setPropLevel() <= Level.ERROR) { + if (level isAtMost Level.ERROR || setPropLevel() isAtMost Level.ERROR) { Log.e(TAG, logMessage) } saveLog(logMessage) @@ -92,7 +99,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun e(parent: Any, message: String, exception: Throwable) { val logMessage = buildMessage(parent, message) - if (level <= Level.ERROR || setPropLevel() <= Level.ERROR) { + if (level isAtMost Level.ERROR || setPropLevel() isAtMost Level.ERROR) { Log.e(TAG, logMessage, exception) } saveLog(logMessage + exception.stackTraceToString()) @@ -100,7 +107,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun w(parent: Any, message: String) { val logMessage = buildMessage(parent, message) - if (level <= Level.WARN || setPropLevel() <= Level.WARN) { + if (level isAtMost Level.WARN || setPropLevel() isAtMost Level.WARN) { Log.w(TAG, logMessage) } saveLog(logMessage) @@ -108,7 +115,7 @@ internal object MindboxLoggerImpl : MindboxLogger { override fun w(parent: Any, message: String, exception: Throwable) { val logMessage = buildMessage(parent, message) - if (level <= Level.WARN || setPropLevel() <= Level.WARN) { + if (level isAtMost Level.WARN || setPropLevel() isAtMost Level.WARN) { Log.w(TAG, logMessage, exception) } saveLog(logMessage + exception.stackTraceToString()) From 5f06f834b689de6b728e5a3968a62be45709cfb9 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 26 May 2026 15:51:44 +0300 Subject: [PATCH 07/37] Add annotation for Level.isAtLeast --- sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt index 592bcd0b..d4585c3c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/logger/Level.kt @@ -12,4 +12,5 @@ public enum class Level(public val value: Int) { internal infix fun Level.isAtMost(other: Level): Boolean = value <= other.value +@Suppress("unused") internal infix fun Level.isAtLeast(other: Level): Boolean = value >= other.value From e83eac559b73376cdecb85b0d97404a026d557da Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 26 May 2026 15:55:18 +0300 Subject: [PATCH 08/37] MOBILE-52: Add timeout for in-app image loading --- .../cloud/mindbox/mobile_sdk/Extensions.kt | 5 + .../managers/InAppGlideImageLoaderImpl.kt | 119 +++++---- .../view/AbstractInAppViewHolder.kt | 115 +++++---- .../managers/InAppGlideImageLoaderImplTest.kt | 237 ++++++++++++++++++ 4 files changed, 376 insertions(+), 100 deletions(-) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt index ca253071..2ec9cb1e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt @@ -149,6 +149,11 @@ internal val Int.dp: Int internal val Int.px: Int get() = (this * Resources.getSystem().displayMetrics.density).roundToInt() +internal fun Context.maxScreenDimension(): Int { + val displayMetrics = resources.displayMetrics + return maxOf(displayMetrics.widthPixels, displayMetrics.heightPixels) +} + internal fun Animation.setOnAnimationEnd(runnable: Runnable) { setAnimationListener(object : AnimationListener { override fun onAnimationStart(animation: Animation?) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt index 8efdca88..2e4ff237 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt @@ -7,14 +7,19 @@ import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageLoader import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppContentFetchingError -import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.maxScreenDimension import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -26,53 +31,73 @@ internal class InAppGlideImageLoaderImpl( private val requests = HashMap>() override suspend fun loadImage(inAppId: String, url: String): Boolean { - mindboxLogD("loading image for inapp with id $inAppId started") - return suspendCancellableCoroutine { cancellableContinuation -> - val target = Glide.with(context).load(url) - .timeout(context.getString(R.string.mindbox_inapp_fetching_timeout).toInt()) - .listener(object : - RequestListener { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - mindboxLogD("loading image with url = $url for inapp with id $inAppId failed") - cancellableContinuation.resumeWithException(InAppContentFetchingError(e)) - true - }.getOrElse { - mindboxLogE( - "Unknown error when loading image from network failed", - exception = it - ) - true - } - } + mindboxLogI("Loading image for inapp with id $inAppId started") + val timeoutMs = context.getString(R.string.mindbox_inapp_fetching_timeout).toLong() + val maxDim = context.maxScreenDimension() + return try { + withTimeout(timeoutMs) { + suspendCancellableCoroutine { continuation -> + requests[inAppId] = startPreload(inAppId, url, maxDim, timeoutMs.toInt(), continuation) + continuation.invokeOnCancellation { cancelLoading(inAppId) } + } + } + } catch (e: TimeoutCancellationException) { + mindboxLogE("Image loading timed out after ${timeoutMs}ms for inapp $inAppId", e) + throw InAppContentFetchingError(null) + } + } + + private fun startPreload( + inAppId: String, + url: String, + maxDim: Int, + timeoutMs: Int, + continuation: CancellableContinuation, + ): Target = Glide.with(context) + .load(url) + .timeout(timeoutMs) + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .override(maxDim, maxDim) + .centerInside() + .listener(buildRequestListener(inAppId, url, continuation)) + .preload(maxDim, maxDim) + + private fun buildRequestListener( + inAppId: String, + url: String, + continuation: CancellableContinuation, + ): RequestListener = object : RequestListener { + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + mindboxLogI("Image loading failed for inapp $inAppId, url = $url") + if (continuation.isActive) { + continuation.resumeWithException(InAppContentFetchingError(e)) + } + return true + } - override fun onResourceReady( - resource: Drawable, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - mindboxLogD("loading image with url = $url for inapp with id $inAppId succeeded") - inAppImageSizeStorage.addSize(inAppId, url, resource.toBitmap().width, resource.toBitmap().height) - cancellableContinuation.resume(true) - true - }.getOrElse { - mindboxLogE( - "Unknown error when loading image from network failed", - exception = it - ) - true - } - } - }).preload() - requests[inAppId] = target + override fun onResourceReady( + resource: Drawable, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + mindboxLogI("Image loading succeeded for inapp $inAppId, url = $url") + if (!continuation.isActive) return true + return runCatching { + val bitmap = resource.toBitmap() + inAppImageSizeStorage.addSize(inAppId, url, bitmap.width, bitmap.height) + continuation.resume(true) + }.onFailure { e -> + mindboxLogE("Failed to process loaded image for inapp $inAppId", e) + continuation.resumeWithException(InAppContentFetchingError(null)) + }.isSuccess } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index b3a94c48..2c065918 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -10,6 +10,7 @@ import android.widget.FrameLayout import android.widget.ImageView import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import cloud.mindbox.mobile_sdk.R import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendPresentationFailure @@ -22,6 +23,7 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageViewDisplayerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import cloud.mindbox.mobile_sdk.inapp.presentation.actions.InAppActionHandler import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.maxScreenDimension import cloud.mindbox.mobile_sdk.removeChildById import cloud.mindbox.mobile_sdk.safeAs import cloud.mindbox.mobile_sdk.setSingleClickListener @@ -122,64 +124,71 @@ internal abstract class AbstractInAppViewHolder( } protected fun getImageFromCache(url: String, imageView: InAppImageView) { + val maxDim = currentDialog.context.maxScreenDimension() + val timeout = currentDialog.context.getString(R.string.mindbox_inapp_fetching_timeout).toInt() Glide .with(currentDialog.context) .load(url) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .listener(object : RequestListener { - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - inAppFailureTracker.sendPresentationFailure( - inAppId = wrapper.inAppType.inAppId, - errorDescription = "Failed to load in-app image with url = $url", - throwable = e - ) - inAppController.close() - false - }.getOrElse { throwable -> - inAppFailureTracker.sendPresentationFailure( - inAppId = wrapper.inAppType.inAppId, - errorDescription = "Unknown error after loading image from cache succeeded", - throwable = throwable - ) - false - } - } + .diskCacheStrategy(DiskCacheStrategy.RESOURCE) + .override(maxDim, maxDim) + .timeout(timeout) + .centerInside() + .listener(buildCacheRequestListener(url, imageView)) + .into(imageView) + } - override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - return runCatching { - bind() - preparedImages[imageView] = true - if (!preparedImages.values.contains(false)) { - this@AbstractInAppViewHolder.mindboxLogI("In-app shown") - wrapper.inAppActionCallbacks.onInAppShown.onShown() - for (image in preparedImages.keys) { - image.visibility = View.VISIBLE - } - } - false - }.getOrElse { throwable -> - inAppFailureTracker.sendPresentationFailure( - inAppId = wrapper.inAppType.inAppId, - errorDescription = "Unknown error in onResourceReady callback", - throwable = throwable - ) - false - } + private fun buildCacheRequestListener( + url: String, + imageView: InAppImageView, + ): RequestListener = object : RequestListener { + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + runCatching { + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Failed to load in-app image with url = $url", + throwable = e + ) + inAppController.close() + }.onFailure { throwable -> + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Unknown error in onLoadFailed callback for url = $url", + throwable = throwable + ) + } + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + runCatching { + bind() + preparedImages[imageView] = true + if (!preparedImages.values.contains(false)) { + mindboxLogI("In-app ${wrapper.inAppType.inAppId} shown") + wrapper.inAppActionCallbacks.onInAppShown.onShown() + preparedImages.keys.forEach { it.isVisible = true } } - }) - .into(imageView) + }.onFailure { throwable -> + inAppFailureTracker.sendPresentationFailure( + inAppId = wrapper.inAppType.inAppId, + errorDescription = "Unknown error in onResourceReady callback for url = $url", + throwable = throwable + ) + } + return false + } } protected open fun initView(currentRoot: ViewGroup) { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt new file mode 100644 index 00000000..5eae97be --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt @@ -0,0 +1,237 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.util.DisplayMetrics +import androidx.core.graphics.drawable.toBitmap +import cloud.mindbox.mobile_sdk.R +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppContentFetchingError +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.RequestManager +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.junit4.MockKRule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class InAppGlideImageLoaderImplTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + private lateinit var context: Context + + @MockK + private lateinit var resources: Resources + + @RelaxedMockK + private lateinit var inAppImageSizeStorage: InAppImageSizeStorage + + @RelaxedMockK + private lateinit var requestManager: RequestManager + + @RelaxedMockK + private lateinit var requestBuilder: RequestBuilder + + private val testDispatcher = StandardTestDispatcher() + private val listenerSlot = slot>() + private lateinit var loader: InAppGlideImageLoaderImpl + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + val displayMetrics = DisplayMetrics().apply { + widthPixels = 1080 + heightPixels = 1920 + } + every { context.resources } returns resources + every { resources.displayMetrics } returns displayMetrics + every { context.getString(R.string.mindbox_inapp_fetching_timeout) } returns "3000" + + mockkStatic(Glide::class) + every { Glide.with(any()) } returns requestManager + every { requestManager.load(any()) } returns requestBuilder + every { requestBuilder.timeout(any()) } returns requestBuilder + every { requestBuilder.diskCacheStrategy(any()) } returns requestBuilder + every { requestBuilder.override(any(), any()) } returns requestBuilder + every { requestBuilder.centerInside() } returns requestBuilder + every { requestBuilder.listener(capture(listenerSlot)) } returns requestBuilder + every { requestBuilder.preload(any(), any()) } returns mockk() + + loader = InAppGlideImageLoaderImpl(context, inAppImageSizeStorage) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + // region loadImage — success + + @Test + fun `loadImage returns true when image loaded successfully`() = runTest { + val bitmap = mockk { + every { width } returns 500 + every { height } returns 300 + } + val drawable = mockk(relaxed = true) + mockkStatic("androidx.core.graphics.drawable.DrawableKt") + every { drawable.toBitmap(any(), any(), any()) } returns bitmap + + var result: Boolean? = null + val job = launch { result = loader.loadImage("id1", URL) } + + runCurrent() + listenerSlot.captured.onResourceReady(drawable, null, null, null, false) + runCurrent() + job.join() + + assertTrue(result == true) + } + + @Test + fun `loadImage stores image dimensions on success`() = runTest { + val bitmap = mockk { + every { width } returns 500 + every { height } returns 300 + } + val drawable = mockk(relaxed = true) + mockkStatic("androidx.core.graphics.drawable.DrawableKt") + every { drawable.toBitmap(any(), any(), any()) } returns bitmap + + val job = launch { runCatching { loader.loadImage("id1", URL) } } + + runCurrent() + listenerSlot.captured.onResourceReady(drawable, null, null, null, false) + runCurrent() + job.join() + + verify(exactly = 1) { inAppImageSizeStorage.addSize("id1", URL, 500, 300) } + } + + // endregion + + // region loadImage — failure + + @Test + fun `loadImage throws InAppContentFetchingError when Glide reports failure`() = runTest { + var thrownException: Throwable? = null + val job = launch { + runCatching { loader.loadImage("id1", URL) }.onFailure { thrownException = it } + } + + runCurrent() + listenerSlot.captured.onLoadFailed(mockk(), null, null, false) + runCurrent() + job.join() + + assertTrue(thrownException is InAppContentFetchingError) + } + + @Test + fun `loadImage throws InAppContentFetchingError when onResourceReady callback throws`() = runTest { + val drawable = mockk(relaxed = true) + mockkStatic("androidx.core.graphics.drawable.DrawableKt") + every { drawable.toBitmap(any(), any(), any()) } throws RuntimeException("decode error") + + var thrownException: Throwable? = null + val job = launch { + runCatching { loader.loadImage("id1", URL) }.onFailure { thrownException = it } + } + + runCurrent() + listenerSlot.captured.onResourceReady(drawable, null, null, null, false) + runCurrent() + job.join() + + assertTrue(thrownException is InAppContentFetchingError) + } + + // endregion + + // region loadImage — timeout + + @Test + fun `loadImage throws InAppContentFetchingError when timeout expires`() = runTest { + var thrownException: Throwable? = null + val job = launch { + runCatching { loader.loadImage("id1", URL) }.onFailure { thrownException = it } + } + + advanceTimeBy(3001) + job.join() + + assertTrue(thrownException is InAppContentFetchingError) + } + + @Test + fun `loadImage does not complete before timeout when no callback fires`() = runTest { + var completed = false + val job = launch { + runCatching { loader.loadImage("id1", URL) } + completed = true + } + + advanceTimeBy(2999) + assertFalse("Job must still be active before timeout", completed) + + job.cancel() + } + + // endregion + + // region cancelLoading + + @Test + fun `cancelLoading clears Glide request for given inAppId`() = runTest { + val target = mockk>(relaxed = true) + every { requestBuilder.preload(any(), any()) } returns target + + val job = launch { runCatching { loader.loadImage("id1", URL) } } + runCurrent() + + loader.cancelLoading("id1") + + verify { requestManager.clear(target) } + job.cancel() + } + + @Test + fun `cancelLoading is called when coroutine is cancelled by timeout`() = runTest { + val target = mockk>(relaxed = true) + every { requestBuilder.preload(any(), any()) } returns target + + val job = launch { runCatching { loader.loadImage("id1", URL) } } + advanceUntilIdle() + + advanceTimeBy(3001) + job.join() + + verify { requestManager.clear(target) } + } + + // endregion + + private companion object { + const val URL = "https://example.com/image.jpg" + } +} From f7d52fc35c86c99d08811ada458c65e417a5b8d1 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Tue, 26 May 2026 16:09:27 +0300 Subject: [PATCH 09/37] Mobile-178: Add custom detekt rules for Gson @SerializedName enforcement --- .github/workflows/lint_unitTests_build.yml | 3 + detekt-rules/build.gradle | 27 ++ .../mindbox/detekt/GsonSerializedNameRule.kt | 173 +++++++++++ .../mindbox/detekt/MindboxRuleSetProvider.kt | 19 ++ ...tlab.arturbosch.detekt.api.RuleSetProvider | 1 + .../detekt/GsonSerializedNameRuleTest.kt | 276 ++++++++++++++++++ detekt.yml | 35 +++ gradle/libs.versions.toml | 7 +- modulesCommon.gradle | 11 + .../inapp/domain/models/GeoTargeting.kt | 8 +- .../domain/models/InAppFailuresWrapper.kt | 3 +- .../models/operation/CustomFields.kt | 4 + .../operation/response/InAppConfigResponse.kt | 12 +- .../mobile_sdk/pushes/MindboxRemoteMessage.kt | 15 +- .../mindbox/mobile_sdk/pushes/PushToken.kt | 5 +- settings.gradle | 1 + 16 files changed, 580 insertions(+), 20 deletions(-) create mode 100644 detekt-rules/build.gradle create mode 100644 detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt create mode 100644 detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt create mode 100644 detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider create mode 100644 detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt create mode 100644 detekt.yml diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index 73064f50..a1a3cc00 100644 --- a/.github/workflows/lint_unitTests_build.yml +++ b/.github/workflows/lint_unitTests_build.yml @@ -50,6 +50,9 @@ jobs: - name: lint check run: ./gradlew --no-daemon lintDebug + - name: detekt check + run: ./gradlew --no-daemon detektDebug + unit: runs-on: ubuntu-latest steps: diff --git a/detekt-rules/build.gradle b/detekt-rules/build.gradle new file mode 100644 index 00000000..9bd9ef6b --- /dev/null +++ b/detekt-rules/build.gradle @@ -0,0 +1,27 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +compileKotlin { + kotlinOptions { + jvmTarget = '11' + } +} + +compileTestKotlin { + kotlinOptions { + jvmTarget = '11' + } +} + +dependencies { + implementation libs.detekt.api + implementation libs.kotlin.stdlib + + testImplementation libs.detekt.test + testImplementation libs.junit +} diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt new file mode 100644 index 00000000..3d5cc9ac --- /dev/null +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt @@ -0,0 +1,173 @@ +package cloud.mindbox.detekt + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.descriptors.ClassDescriptor +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassLiteralExpression +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtObjectDeclaration +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.resolve.BindingContext +import org.jetbrains.kotlin.resolve.DescriptorToSourceUtils +import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall +import org.jetbrains.kotlin.resolve.calls.util.getType +import org.jetbrains.kotlin.types.KotlinType + +class GsonSerializedNameRule(config: Config) : Rule(config) { + + override val issue: Issue = Issue( + id = "GsonMissingSerializedName", + severity = Severity.Defect, + description = "Gson-serialized Kotlin data class constructor properties must declare @SerializedName.", + debt = Debt.FIVE_MINS + ) + + private val reportedParameterKeys: MutableSet = mutableSetOf() + private val checkedClasses: MutableSet = mutableSetOf() + + override fun preVisit(root: KtFile) { + reportedParameterKeys.clear() + } + + override fun visitClass(klass: KtClass) { + super.visitClass(klass) + if (!klass.isData()) return + if (!klass.hasSerializedNameContract()) return + reportMissingSerializedNameParameters(klass) + } + + override fun visitCallExpression(expression: KtCallExpression) { + super.visitCallExpression(expression) + val calleeName = expression.calleeExpression?.text ?: return + if (calleeName in DIRECT_GSON_FUNCTION_NAMES) { + checkFirstArgumentType(expression) + checkTypeArguments(expression) + checkClassLiteralArguments(expression) + } else { + checkIfIndirectGsonCall(expression) + } + } + + override fun visitObjectDeclaration(declaration: KtObjectDeclaration) { + super.visitObjectDeclaration(declaration) + declaration.superTypeListEntries + .filter { entry -> entry.typeReference?.text?.contains(TYPE_TOKEN) == true } + .forEach { entry -> + val type = bindingContext[BindingContext.TYPE, entry.typeReference] ?: return@forEach + type.arguments + .filterNot { projection -> projection.isStarProjection } + .forEach { projection -> checkKotlinType(projection.type) } + } + } + + private fun checkIfIndirectGsonCall(expression: KtCallExpression) { + val resolvedCall = expression.getResolvedCall(bindingContext) ?: return + val sourceFunction = DescriptorToSourceUtils + .descriptorToDeclaration(resolvedCall.resultingDescriptor) as? KtNamedFunction ?: return + if (sourceFunction.typeParameters.isEmpty()) return + if (!sourceFunction.containsDirectGsonCall()) return + checkFirstArgumentType(expression) + checkTypeArguments(expression) + checkClassLiteralArguments(expression) + } + + private fun KtNamedFunction.containsDirectGsonCall(): Boolean = + collectDescendantsOfType() + .any { call -> call.calleeExpression?.text in DIRECT_GSON_FUNCTION_NAMES } + + private fun checkFirstArgumentType(expression: KtCallExpression) { + val argument = expression.valueArguments.firstOrNull()?.getArgumentExpression() ?: return + checkKotlinType(argument.getType(bindingContext) ?: return) + } + + private fun checkTypeArguments(expression: KtCallExpression) { + expression.typeArguments + .mapNotNull { typeProjection -> typeProjection.typeReference } + .mapNotNull { typeRef -> bindingContext[BindingContext.TYPE, typeRef] } + .forEach { type -> checkKotlinType(type) } + } + + private fun checkClassLiteralArguments(expression: KtCallExpression) { + expression.valueArguments + .mapNotNull { argument -> argument.getArgumentExpression() } + .forEach { argument -> + val classLiteral = when (argument) { + is KtDotQualifiedExpression -> argument.receiverExpression as? KtClassLiteralExpression + is KtClassLiteralExpression -> argument + else -> null + } ?: return@forEach + val type = classLiteral.getType(bindingContext) ?: return@forEach + type.arguments.firstOrNull() + ?.takeIf { projection -> !projection.isStarProjection } + ?.type + ?.let(::checkKotlinType) + } + } + + private fun checkKotlinType(type: KotlinType) { + val descriptor = type.constructor.declarationDescriptor as? ClassDescriptor ?: return + val sourceClass = DescriptorToSourceUtils.descriptorToDeclaration(descriptor) as? KtClass + sourceClass?.let(::reportMissingSerializedNameParameters) + type.arguments + .filterNot { projection -> projection.isStarProjection } + .forEach { projection -> checkKotlinType(projection.type) } + } + + private fun reportMissingSerializedNameParameters(klass: KtClass) { + if (!klass.isData()) return + val qualifiedName = klass.fqName?.asString() ?: return + if (!checkedClasses.add(qualifiedName)) return + klass.primaryConstructorParameters + .filter { parameter -> parameter.valOrVarKeyword != null } + .forEach { parameter -> + if (!parameter.hasSerializedNameAnnotation()) reportParameter(parameter, klass) + val typeRef = parameter.typeReference ?: return@forEach + val type = bindingContext[BindingContext.TYPE, typeRef] ?: return@forEach + checkKotlinType(type) + } + } + + private fun reportParameter(parameter: KtParameter, klass: KtClass) { + val key = "${parameter.containingFile.name}:${parameter.textOffset}" + if (!reportedParameterKeys.add(key)) return + report( + CodeSmell( + issue = issue, + entity = Entity.from(parameter), + message = "${klass.name}.${parameter.name} must declare @SerializedName." + ) + ) + } + + private fun KtClass.hasSerializedNameContract(): Boolean { + return primaryConstructorParameters.any { parameter -> parameter.hasSerializedNameAnnotation() } + } + + private fun KtParameter.hasSerializedNameAnnotation(): Boolean { + return annotationEntries.any { annotationEntry -> + annotationEntry.shortName?.asString() == SERIALIZED_NAME + } + } + + private companion object { + private const val SERIALIZED_NAME = "SerializedName" + private const val TYPE_TOKEN = "TypeToken" + + private val DIRECT_GSON_FUNCTION_NAMES: Set = setOf( + "fromJson", + "fromJsonTree", + "toJson", + "toJsonTree", + ) + } +} diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt new file mode 100644 index 00000000..1978f0bd --- /dev/null +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt @@ -0,0 +1,19 @@ +package cloud.mindbox.detekt + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.RuleSet +import io.gitlab.arturbosch.detekt.api.RuleSetProvider + +class MindboxRuleSetProvider : RuleSetProvider { + + override val ruleSetId: String = "mindbox" + + override fun instance(config: Config): RuleSet { + return RuleSet( + id = ruleSetId, + rules = listOf( + GsonSerializedNameRule(config = config), + ) + ) + } +} diff --git a/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider new file mode 100644 index 00000000..9f5c9241 --- /dev/null +++ b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider @@ -0,0 +1 @@ +cloud.mindbox.detekt.MindboxRuleSetProvider diff --git a/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt new file mode 100644 index 00000000..395fbdc3 --- /dev/null +++ b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt @@ -0,0 +1,276 @@ +package cloud.mindbox.detekt + +import io.github.detekt.test.utils.createEnvironment +import io.gitlab.arturbosch.detekt.api.Finding +import io.gitlab.arturbosch.detekt.test.TestConfig +import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext +import org.junit.AfterClass +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GsonSerializedNameRuleTest { + + private val stubs = """ + annotation class SerializedName(val value: String) + abstract class TypeToken + fun fromJson(json: String, clazz: Class<*>): Any = error("stub") + fun fromJson(json: String, clazz: Class): T = error("stub") + fun toJson(obj: Any?): String = error("stub") + fun fromJsonTyped(json: String, clazz: Class): T = fromJson(json, clazz) + fun toJsonTyped(obj: T): String = toJson(obj) + fun operationBodyJson(obj: T): String = toJson(obj) + fun convertJsonToBody(json: String, clazz: Class): T = fromJson(json, clazz) + """.trimIndent() + + private fun compileAndLint(code: String): List = + GsonSerializedNameRule(TestConfig()).compileAndLintWithContext(env, code) + + @Test + fun `reports class passed via class literal to fromJson`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + fun test(json: String): Any = fromJson(json, MyDto::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports class passed as explicit type argument to fromJson`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + fun test(json: String): MyDto = fromJson(json, MyDto::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports class passed as first argument to toJson`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + fun test() = toJson(MyDto("x")) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports class when toJson called with this inside the class`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) { + fun serialize() = toJson(this) + } + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports class passed via operationBodyJson`() { + val findings = compileAndLint( + """ + $stubs + data class RequestBody(val action: String) + fun test() = operationBodyJson(RequestBody("click")) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("RequestBody.action")) + } + + @Test + fun `reports class passed via fromJsonTyped class literal`() { + val findings = compileAndLint( + """ + $stubs + data class ResponseBody(val status: String) + fun test(json: String): ResponseBody = fromJsonTyped(json, ResponseBody::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("ResponseBody.status")) + } + + @Test + fun `reports class passed via convertJsonToBody`() { + val findings = compileAndLint( + """ + $stubs + data class EventBody(val type: String) + fun test(json: String): Any = convertJsonToBody(json, EventBody::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("EventBody.type")) + } + + @Test + fun `reports class via custom auto-detected generic wrapper`() { + val findings = compileAndLint( + """ + $stubs + data class Payload(val id: String) + fun myDeserializer(json: String, clazz: Class): T = fromJson(json, clazz) + fun test(json: String) = myDeserializer(json, Payload::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("Payload.id")) + } + + @Test + fun `does not report class passed to a function that does not wrap Gson`() { + val findings = compileAndLint( + """ + $stubs + data class Config(val key: String) + fun jacksonDeserialize(json: String, clazz: Class): T = error("stub") + fun test(json: String) = jacksonDeserialize(json, Config::class.java) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `reports class used as direct TypeToken generic argument`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + val token = object : TypeToken() {} + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports class nested inside List in TypeToken`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + val token = object : TypeToken>() {} + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) + } + + @Test + fun `reports transitive field type missing annotation`() { + val findings = compileAndLint( + """ + $stubs + data class Inner(val value: Int) + data class Outer(val inner: Inner) + fun test() = toJson(Outer(Inner(1))) + """.trimIndent() + ) + assertEquals(2, findings.size) + assertTrue(findings.any { it.message.contains("Outer.inner") }) + assertTrue(findings.any { it.message.contains("Inner.value") }) + } + + @Test + fun `reports class nested inside generic List field transitively`() { + val findings = compileAndLint( + """ + $stubs + data class Item(val id: Int) + data class Page(val items: List) + fun test() = toJson(Page(emptyList())) + """.trimIndent() + ) + assertTrue(findings.any { it.message.contains("Page.items") }) + assertTrue(findings.any { it.message.contains("Item.id") }) + } + + @Test + fun `reports missing annotation when class has partial SerializedName contract`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto( + @SerializedName("name") val name: String, + val age: Int + ) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.age")) + } + + @Test + fun `does not report data class not involved in any Gson call`() { + val findings = compileAndLint( + """ + $stubs + data class Config(val key: String, val value: Int) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `does not report when all fields have SerializedName`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto( + @SerializedName("name") val name: String, + @SerializedName("age") val age: Int + ) + fun test() = toJson(MyDto("x", 1)) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `does not report non-data class passed to toJson`() { + val findings = compileAndLint( + """ + $stubs + class NotADataClass(val name: String) + fun test() = toJson(NotADataClass("x")) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `does not report stdlib types like String or List`() { + val findings = compileAndLint( + """ + $stubs + fun test() = toJson(listOf("a", "b")) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + companion object { + private val environmentWrapper = createEnvironment() + private val env get() = environmentWrapper.env + + @AfterClass @JvmStatic + fun tearDown() { + environmentWrapper.dispose() + } + } +} diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 00000000..81724dec --- /dev/null +++ b/detekt.yml @@ -0,0 +1,35 @@ +config: + validation: true + excludes: "mindbox.*,mindbox>.*>.*" + +comments: + active: false + +complexity: + active: false + +coroutines: + active: false + +empty-blocks: + active: false + +exceptions: + active: false + +naming: + active: false + +performance: + active: false + +potential-bugs: + active: false + +style: + active: false + +mindbox: + active: true + GsonMissingSerializedName: + active: true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 604bf1db..256d8e47 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ ktlint-plugin = "12.1.1" ksp = "1.9.22-1.0.17" maven_publish = "0.32.0" kover = "0.8.3" +detekt = "1.23.6" pushclient = "7.2.0" @@ -54,6 +55,7 @@ buildscript-plugins = [ "ksp_gradle_plugin", "maven_publish_plugin", "kover_gradle_plugin", + "detekt_gradle_plugin", ] test = [ @@ -121,4 +123,7 @@ agcp = { module = "com.huawei.agconnect:agcp", version.ref = "agcp" } ktlint_gradle_plugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint-plugin" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } maven_publish_plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven_publish" } -kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } \ No newline at end of file +kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } +detekt_gradle_plugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } +detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } +detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } diff --git a/modulesCommon.gradle b/modulesCommon.gradle index 3aeb5ac7..9e621e5e 100644 --- a/modulesCommon.gradle +++ b/modulesCommon.gradle @@ -4,6 +4,7 @@ apply plugin: 'signing' apply plugin: 'org.jlleitschuh.gradle.ktlint' apply plugin: 'com.vanniktech.maven.publish' apply plugin: 'org.jetbrains.kotlinx.kover' +apply plugin: 'io.gitlab.arturbosch.detekt' group = 'com.github.mindbox-cloud' @@ -52,6 +53,16 @@ android { } } +detekt { + buildUponDefaultConfig = true + config.setFrom(files("${rootDir}/detekt.yml")) + source.setFrom(files("src/main/java", "src/main/kotlin")) +} + +dependencies { + detektPlugins project(path: ':detekt-rules', configuration: 'runtimeElements') +} + mavenPublishing { publishToMavenCentral("CENTRAL_PORTAL") diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt index 1bb81a18..f58fca72 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt @@ -1,7 +1,9 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models +import com.google.gson.annotations.SerializedName + internal data class GeoTargeting( - val cityId: String, - val regionId: String, - val countryId: String, + @SerializedName("cityId") val cityId: String, + @SerializedName("regionId") val regionId: String, + @SerializedName("countryId") val countryId: String, ) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt index 2596787a..c1426329 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt @@ -1,7 +1,8 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import com.google.gson.annotations.SerializedName internal data class InAppFailuresWrapper( - val failures: List + @SerializedName("failures") val failures: List ) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt index 8439d675..9cdafecb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt @@ -13,6 +13,10 @@ public class CustomFields(public val fields: Map? = null) { * Convert [CustomFields] value to [T] typed object. * * @param classOfT Class type for result [CustomFields] object. + * + * **Important:** [T] is a caller-supplied type deserialized via Gson. All constructor + * properties of [T] must declare [@SerializedName][com.google.gson.annotations.SerializedName] + * to ensure correct serialization after code shrinking (ProGuard/R8). */ public fun convertTo(classOfT: Class): T? = LoggingExceptionHandler.runCatching(defaultValue = null) { val gson = Gson() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt index de99b37d..0a1ad6f6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt @@ -75,7 +75,7 @@ internal data class SettingsDtoBlank( @JsonAdapter(FeatureTogglesDtoBlankDeserializer::class) internal data class FeatureTogglesDtoBlank( - val toggles: Map + @SerializedName("toggles") val toggles: Map ) } @@ -110,14 +110,14 @@ internal data class TtlDto( ) internal data class SlidingExpirationDto( - val config: Milliseconds?, - val pushTokenKeepalive: Milliseconds?, + @SerializedName("config") val config: Milliseconds?, + @SerializedName("pushTokenKeepalive") val pushTokenKeepalive: Milliseconds?, ) internal data class InappSettingsDto( - val maxInappsPerSession: Int?, - val maxInappsPerDay: Int?, - val minIntervalBetweenShows: Milliseconds?, + @SerializedName("maxInappsPerSession") val maxInappsPerSession: Int?, + @SerializedName("maxInappsPerDay") val maxInappsPerDay: Int?, + @SerializedName("minIntervalBetweenShows") val minIntervalBetweenShows: Milliseconds?, ) internal data class LogRequestDto( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt index b85d0d47..9083fad8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.PendingIntent import android.content.Context import android.os.Bundle +import com.google.gson.annotations.SerializedName /** * A class representing mindbox remote message @@ -11,13 +12,13 @@ import android.os.Bundle * with your custom push notification implementation. * */ public data class MindboxRemoteMessage( - val uniqueKey: String, - val title: String, - val description: String, - val pushActions: List, - val pushLink: String?, - val imageUrl: String?, - val payload: String?, + @SerializedName("uniqueKey") val uniqueKey: String, + @SerializedName("title") val title: String, + @SerializedName("description") val description: String, + @SerializedName("pushActions") val pushActions: List, + @SerializedName("pushLink") val pushLink: String?, + @SerializedName("imageUrl") val imageUrl: String?, + @SerializedName("payload") val payload: String?, ) { public companion object { public const val DATA_UNIQUE_KEY: String = "uniqueKey" diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt index d339a096..0ce64511 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt @@ -6,6 +6,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch import cloud.mindbox.mobile_sdk.utils.awaitAllWithTimeout import com.google.gson.Gson +import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken import kotlinx.coroutines.async import kotlinx.coroutines.withContext @@ -18,8 +19,8 @@ internal data class PushToken( ) internal data class PrefPushToken( - val token: String, - val updateDate: Long, + @SerializedName("token") val token: String, + @SerializedName("updateDate") val updateDate: Long, ) internal typealias PushTokenMap = Map diff --git a/settings.gradle b/settings.gradle index 18e56530..0ac91206 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,5 @@ include ':sdk' +include ':detekt-rules' include ':mindbox-huawei' include ':mindbox-firebase' include ':mindbox-rustore' From 6629af0a93d269ca684819d5d01f5bdfcd4b4ed4 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 26 May 2026 16:34:10 +0300 Subject: [PATCH 10/37] MOBILE-52: Add requests.remove(inAppId) --- .../mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt index 2e4ff237..4f46c94f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt @@ -75,6 +75,7 @@ internal class InAppGlideImageLoaderImpl( isFirstResource: Boolean, ): Boolean { mindboxLogI("Image loading failed for inapp $inAppId, url = $url") + requests.remove(inAppId) if (continuation.isActive) { continuation.resumeWithException(InAppContentFetchingError(e)) } @@ -90,6 +91,7 @@ internal class InAppGlideImageLoaderImpl( ): Boolean { mindboxLogI("Image loading succeeded for inapp $inAppId, url = $url") if (!continuation.isActive) return true + requests.remove(inAppId) return runCatching { val bitmap = resource.toBitmap() inAppImageSizeStorage.addSize(inAppId, url, bitmap.width, bitmap.height) From f19588124743c200803800aa48e0848d9815b503 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Wed, 27 May 2026 11:51:55 +0300 Subject: [PATCH 11/37] MOBILE-52: Follow code review --- .../inapp/data/managers/InAppGlideImageLoaderImpl.kt | 3 ++- .../inapp/data/managers/InAppGlideImageLoaderImplTest.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt index 4f46c94f..c8c6878c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt @@ -16,6 +16,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.suspendCancellableCoroutine @@ -28,7 +29,7 @@ internal class InAppGlideImageLoaderImpl( private val inAppImageSizeStorage: InAppImageSizeStorage ) : InAppImageLoader { - private val requests = HashMap>() + private val requests = ConcurrentHashMap>() override suspend fun loadImage(inAppId: String, url: String): Boolean { mindboxLogI("Loading image for inapp with id $inAppId started") diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt index 5eae97be..2c9d2d4e 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt @@ -221,7 +221,7 @@ internal class InAppGlideImageLoaderImplTest { every { requestBuilder.preload(any(), any()) } returns target val job = launch { runCatching { loader.loadImage("id1", URL) } } - advanceUntilIdle() + runCurrent() advanceTimeBy(3001) job.join() From 0d1556154f431d820db14c88599cf1899ee031cb Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 1 Jun 2026 14:22:07 +0300 Subject: [PATCH 12/37] MOBILE-52: Fix snackbar cross and cross margins --- .../cloud/mindbox/mobile_sdk/Extensions.kt | 2 +- .../view/InAppConstraintLayout.kt | 13 ++++++ .../inapp/presentation/view/InAppCrossView.kt | 42 +++++++------------ .../inapp/presentation/view/InAppImageView.kt | 42 ++++--------------- .../managers/InAppGlideImageLoaderImplTest.kt | 1 + 5 files changed, 39 insertions(+), 61 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt index 2ec9cb1e..a6ae6ddc 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt @@ -150,7 +150,7 @@ internal val Int.px: Int get() = (this * Resources.getSystem().displayMetrics.density).roundToInt() internal fun Context.maxScreenDimension(): Int { - val displayMetrics = resources.displayMetrics + val displayMetrics = applicationContext.resources.displayMetrics return maxOf(displayMetrics.widthPixels, displayMetrics.heightPixels) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt index aa436746..6fa85069 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppConstraintLayout.kt @@ -7,6 +7,7 @@ import android.view.* import android.view.animation.AccelerateDecelerateInterpolator import android.widget.FrameLayout import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet import androidx.core.view.* import cloud.mindbox.mobile_sdk.SnackbarPosition import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType @@ -292,3 +293,15 @@ internal data class InAppInsets( internal fun interface BackButtonLayout { fun setBackListener(listener: (() -> Unit)?) } + +/** + * Clones the current constraint state, applies [block] to it, then commits the result back. + * Removes the clone/applyTo boilerplate from call sites. + */ +internal fun InAppConstraintLayout.updateConstraints(block: ConstraintSet.() -> Unit) { + ConstraintSet().apply { + clone(this@updateConstraints) + block() + applyTo(this@updateConstraints) + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppCrossView.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppCrossView.kt index e2c505d4..f0fce31e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppCrossView.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppCrossView.kt @@ -2,18 +2,19 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.content.Context import android.graphics.Canvas -import android.graphics.Color import android.graphics.Paint import android.view.View import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.view.doOnLayout import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import cloud.mindbox.mobile_sdk.inapp.domain.models.Element import cloud.mindbox.mobile_sdk.inapp.domain.models.Element.CloseButton.Position.Kind.PROPORTION import cloud.mindbox.mobile_sdk.inapp.domain.models.Element.CloseButton.Size.Kind.DP -import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.px import kotlin.math.roundToInt +import androidx.core.graphics.toColorInt +import cloud.mindbox.mobile_sdk.logger.mindboxLogI internal class InAppCrossView : View { @@ -76,32 +77,21 @@ internal class InAppCrossView : View { updateLayoutParams { width = crossWidth height = crossHeight - paint.color = Color.parseColor(closeButtonElement.color) + paint.color = closeButtonElement.color.toColorInt() } - val constraintSet = ConstraintSet() - val marginTop = - ((currentDialog.height - crossHeight) * closeButtonElement.position.top).roundToInt() - val marginEnd = - ((currentDialog.width - crossWidth) * closeButtonElement.position.right).roundToInt() + currentDialog.doOnLayout { + val marginTop = + ((currentDialog.height - crossHeight) * closeButtonElement.position.top).roundToInt() + val marginEnd = + ((currentDialog.width - crossWidth) * closeButtonElement.position.right).roundToInt() - constraintSet.clone(currentDialog) - constraintSet.connect( - id, - ConstraintSet.TOP, - currentDialog.id, - ConstraintSet.TOP, - marginTop - ) - constraintSet.connect( - id, - ConstraintSet.END, - currentDialog.id, - ConstraintSet.END, - marginEnd - ) - constraintSet.applyTo(currentDialog) - mindboxLogD("InApp cross is shown with params:color = ${closeButtonElement.color}, lineWidth = ${closeButtonElement.lineWidth}, width = ${closeButtonElement.size.width}, height = ${closeButtonElement.size.height} with kind ${closeButtonElement.size.kind.name}. Margins: top = ${closeButtonElement.position.top}, bottom = ${closeButtonElement.position.bottom}, left = ${closeButtonElement.position.left}, right = ${closeButtonElement.position.right} and kind ${closeButtonElement.position.kind.name}") - if (paint.strokeWidth == 0f || crossWidth == 0 || crossHeight == 0) isVisible = false + currentDialog.updateConstraints { + connect(id, ConstraintSet.TOP, currentDialog.id, ConstraintSet.TOP, marginTop) + connect(id, ConstraintSet.END, currentDialog.id, ConstraintSet.END, marginEnd) + } + mindboxLogI("InApp cross is shown with params:color = ${closeButtonElement.color}, lineWidth = ${closeButtonElement.lineWidth}, width = ${closeButtonElement.size.width}, height = ${closeButtonElement.size.height} with kind ${closeButtonElement.size.kind.name}. Margins: top = ${closeButtonElement.position.top}, bottom = ${closeButtonElement.position.bottom}, left = ${closeButtonElement.position.left}, right = ${closeButtonElement.position.right} and kind ${closeButtonElement.position.kind.name}") + if (paint.strokeWidth == 0f || crossWidth == 0 || crossHeight == 0) isVisible = false + } } fun prepareViewForModalWindow(currentDialog: InAppConstraintLayout) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppImageView.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppImageView.kt index 588ad477..b851a391 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppImageView.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppImageView.kt @@ -1,7 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.content.Context -import android.widget.FrameLayout import androidx.appcompat.widget.AppCompatImageView import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet @@ -24,7 +23,7 @@ internal class InAppImageView(context: Context) : AppCompatImageView(context) { val oneThirdScreenHeight = resources.displayMetrics.heightPixels / 3 val desiredHeight = (((resources.displayMetrics.widthPixels.toDouble() - marginStart.toDouble() - marginEnd.toDouble()) / (size.width.toDouble())) * size.height).roundToInt() - layoutParams = FrameLayout.LayoutParams( + layoutParams = ConstraintLayout.LayoutParams( ConstraintLayout.LayoutParams.MATCH_PARENT, if (desiredHeight > oneThirdScreenHeight) oneThirdScreenHeight else desiredHeight ) @@ -36,38 +35,13 @@ internal class InAppImageView(context: Context) : AppCompatImageView(context) { width = 0.dp height = 0.dp } - val constraintSet = ConstraintSet() - constraintSet.clone(currentDialog) - constraintSet.setDimensionRatio(id, MODAL_WINDOW_ASPECT_RATIO) scaleType = ScaleType.CENTER_CROP - constraintSet.connect( - id, - ConstraintSet.TOP, - currentDialog.id, - ConstraintSet.TOP, - 0 - ) - constraintSet.connect( - id, - ConstraintSet.END, - currentDialog.id, - ConstraintSet.END, - 0 - ) - constraintSet.connect( - id, - ConstraintSet.START, - currentDialog.id, - ConstraintSet.START, - 0 - ) - constraintSet.connect( - id, - ConstraintSet.BOTTOM, - currentDialog.id, - ConstraintSet.BOTTOM, - 0 - ) - constraintSet.applyTo(currentDialog) + currentDialog.updateConstraints { + setDimensionRatio(id, MODAL_WINDOW_ASPECT_RATIO) + connect(id, ConstraintSet.TOP, currentDialog.id, ConstraintSet.TOP, 0) + connect(id, ConstraintSet.END, currentDialog.id, ConstraintSet.END, 0) + connect(id, ConstraintSet.START, currentDialog.id, ConstraintSet.START, 0) + connect(id, ConstraintSet.BOTTOM, currentDialog.id, ConstraintSet.BOTTOM, 0) + } } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt index 2c9d2d4e..6a01ed8e 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt @@ -62,6 +62,7 @@ internal class InAppGlideImageLoaderImplTest { widthPixels = 1080 heightPixels = 1920 } + every { context.applicationContext } returns context every { context.resources } returns resources every { resources.displayMetrics } returns displayMetrics every { context.getString(R.string.mindbox_inapp_fetching_timeout) } returns "3000" From 89d73e268b08be6c4c764f94beb8eb00bfc1141c Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 2 Jun 2026 17:59:30 +0300 Subject: [PATCH 13/37] MOBILE-52: Fix snackbar animation --- .../inapp/presentation/view/SnackbarInAppViewHolder.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt index 49ce7af4..1f4c1a12 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/SnackbarInAppViewHolder.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.view.ViewGroup +import androidx.core.view.doOnLayout import androidx.core.view.isInvisible import cloud.mindbox.mobile_sdk.SnackbarPosition import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppImageSizeStorage @@ -92,9 +93,11 @@ internal class SnackbarInAppViewHolder( } } if (isFirstShow) { - when (wrapper.inAppType.position.gravity.vertical) { - SnackbarPosition.TOP -> inAppLayout.slideDown() - SnackbarPosition.BOTTOM -> inAppLayout.slideUp() + currentDialog.doOnLayout { + when (wrapper.inAppType.position.gravity.vertical) { + SnackbarPosition.TOP -> inAppLayout.slideDown() + SnackbarPosition.BOTTOM -> inAppLayout.slideUp() + } } } } From c960bae913ec0d9f96f7c6e89672f41a27390710 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:09:15 +0300 Subject: [PATCH 14/37] Mobile-136: Fix UrlInAppCallback --- .../inapp/presentation/ActivityManagerImpl.kt | 2 +- .../InAppMessageViewDisplayerImpl.kt | 1 - .../callbacks/DeepLinkInAppCallback.kt | 11 +++++++- .../callbacks/UrlInAppCallback.kt | 10 ++++++- .../inapp/presentation/ActivityManagerTest.kt | 27 +++++++++---------- 5 files changed, 32 insertions(+), 19 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerImpl.kt index 8d362d69..26e7316b 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerImpl.kt @@ -16,7 +16,7 @@ internal class ActivityManagerImpl( try { Intent(Intent.ACTION_VIEW, Uri.parse(url)).also { intent -> intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - return if (callbackInteractor.isValidUrl(url) && intent.resolveActivity(context.packageManager) == null) { + return if (callbackInteractor.isValidUrl(url) && intent.resolveActivity(context.packageManager) != null) { context.startActivity(intent) true } else { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 93af840a..3a978bea 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -48,7 +48,6 @@ internal class InAppMessageViewDisplayerImpl( private var currentActivity: Activity? = null private val defaultCallback: InAppCallback = ComposableInAppCallback( - UrlInAppCallback(), DeepLinkInAppCallback(), CopyPayloadInAppCallback(), LoggingInAppCallback() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/DeepLinkInAppCallback.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/DeepLinkInAppCallback.kt index 036e08ea..7959f08a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/DeepLinkInAppCallback.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/DeepLinkInAppCallback.kt @@ -5,7 +5,16 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.ActivityManager import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback /** - * Ready-to-use implementation of InAppCallback that handles opening deeplink if it's possible + * Ready-to-use implementation of [InAppCallback] that opens a link of any scheme if there is an + * activity able to handle it. + * + * The link is opened via an `ACTION_VIEW` intent and the system decides where it goes: a custom + * deep link or a verified Android App Link is opened inside the app, while a regular http/https + * link is opened in the browser. In other words, this callback covers both deep links and plain + * web urls, so it is the only link-opening callback needed in the default chain. + * + * Do NOT combine it with [UrlInAppCallback] in the same chain: both would issue `startActivity` + * for the same http/https link, opening it twice. **/ public open class DeepLinkInAppCallback : InAppCallback { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/UrlInAppCallback.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/UrlInAppCallback.kt index a366de33..8a5f4e54 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/UrlInAppCallback.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/callbacks/UrlInAppCallback.kt @@ -5,7 +5,15 @@ import cloud.mindbox.mobile_sdk.inapp.presentation.ActivityManager import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback /** - * Ready-to-use implementation of InAppCallback that handles opening url in browser + * Ready-to-use implementation of [InAppCallback] that opens only http/https links. + * + * The link is opened via an `ACTION_VIEW` intent and the system chooses the handler: a regular + * web link goes to the browser, while a verified Android App Link may be opened inside the + * corresponding app. Non-http/https schemes (e.g. custom deep links) are ignored — use + * [DeepLinkInAppCallback] for those. + * + * Do NOT combine this callback with [DeepLinkInAppCallback] in the same chain: both would issue + * `startActivity` for the same http/https link, opening it twice. **/ public open class UrlInAppCallback : InAppCallback { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerTest.kt index 07e46426..0c6e9dfe 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/ActivityManagerTest.kt @@ -34,6 +34,17 @@ internal class ActivityManagerTest { context = context ) val url = "https://mindbox.ru" + val componentName = "testComponentName" + val packageName = "com.example" + val packageManager = shadowOf(RuntimeEnvironment.getApplication().packageManager) + packageManager.addActivityIfNotPresent(ComponentName(packageName, componentName)) + packageManager.addIntentFilterForActivity( + ComponentName(packageName, componentName), + IntentFilter(Intent.ACTION_VIEW).apply { + addCategory(Intent.CATEGORY_DEFAULT) + addDataScheme("https") + } + ) every { callbackInteractor.isValidUrl(url) } returns true @@ -78,24 +89,10 @@ internal class ActivityManagerTest { } @Test - fun `try open url doesn't open deeplink`() { + fun `tryOpenUrl should return false when no activity can handle url`() { context = ApplicationProvider.getApplicationContext() activityManager = ActivityManagerImpl(callbackInteractor, context) val url = "https://pushok-mindbox.onelink.me/13Z2/a97bb56f" - val componentName = "testComponentName" - val packageName = "com.example" - val packageManager = shadowOf(RuntimeEnvironment.getApplication().packageManager) - packageManager.addActivityIfNotPresent(ComponentName(packageName, componentName)) - packageManager.addIntentFilterForActivity( - ComponentName( - packageName, - componentName - ), - IntentFilter(Intent.ACTION_VIEW).apply { - addCategory(Intent.CATEGORY_DEFAULT) - addDataScheme("https") - } - ) every { callbackInteractor.isValidUrl(url) } returns true From 3377617de2fceb90c9b3772a73256470f5c77649 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 4 Jun 2026 13:47:56 +0300 Subject: [PATCH 15/37] MOBILE-52: Fix Inapp.ShowFailure on image timeout --- .../data/managers/InAppGlideImageLoaderImpl.kt | 5 +++-- .../inapp/domain/InAppProcessingManagerImpl.kt | 2 +- .../extensions/TrackingFailureExtension.kt | 2 ++ .../inapp/domain/models/InAppDataError.kt | 6 ++++-- .../view/AbstractInAppViewHolder.kt | 2 +- .../managers/InAppGlideImageLoaderImplTest.kt | 2 +- .../extensions/TrackingFailureExtensionTest.kt | 17 +++++++++++++++++ 7 files changed, 29 insertions(+), 7 deletions(-) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt index c8c6878c..aa3a650a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImpl.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +import kotlin.time.Duration.Companion.milliseconds internal class InAppGlideImageLoaderImpl( private val context: Context, @@ -36,7 +37,7 @@ internal class InAppGlideImageLoaderImpl( val timeoutMs = context.getString(R.string.mindbox_inapp_fetching_timeout).toLong() val maxDim = context.maxScreenDimension() return try { - withTimeout(timeoutMs) { + withTimeout(timeoutMs.milliseconds) { suspendCancellableCoroutine { continuation -> requests[inAppId] = startPreload(inAppId, url, maxDim, timeoutMs.toInt(), continuation) continuation.invokeOnCancellation { cancelLoading(inAppId) } @@ -44,7 +45,7 @@ internal class InAppGlideImageLoaderImpl( } } catch (e: TimeoutCancellationException) { mindboxLogE("Image loading timed out after ${timeoutMs}ms for inapp $inAppId", e) - throw InAppContentFetchingError(null) + throw InAppContentFetchingError(e) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt index 421e25a2..271a93f0 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt @@ -131,7 +131,7 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, - errorDetails = error.message + "\n Url is ${inApp.form.variants.first().getImageUrl()}" + errorDetails = (error.message ?: "Image loading error") + "\n Url is ${inApp.form.variants.first().getImageUrl()}" ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt index 663c70be..2f15015d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt @@ -16,6 +16,7 @@ import java.net.SocketTimeoutException import java.net.UnknownHostException import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import kotlinx.coroutines.TimeoutCancellationException internal fun VolleyError.isTimeoutError(): Boolean { return this is TimeoutError || cause is SocketTimeoutException @@ -36,6 +37,7 @@ internal fun Throwable.shouldTrackTargetingError(): Boolean { } internal fun Throwable.shouldTrackImageDownloadError(): Boolean { + if (cause is TimeoutCancellationException) return false val glideException = cause as? GlideException ?: return true return glideException.rootCauses.none { rootCause -> when { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppDataError.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppDataError.kt index 2f25843d..bbae479a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppDataError.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppDataError.kt @@ -1,7 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models import com.android.volley.VolleyError -import com.bumptech.glide.load.engine.GlideException internal class CustomerSegmentationError(volleyError: VolleyError) : Exception(volleyError) @@ -11,4 +10,7 @@ internal class GeoError(volleyError: VolleyError) : Exception(volleyError) internal class ProductSegmentationError(volleyError: VolleyError) : Exception(volleyError) -internal class InAppContentFetchingError(error: GlideException?) : Exception(error) +internal class InAppContentFetchingError( + cause: Throwable?, + message: String? = cause?.message ?: "Image loading error", +) : Exception(message, cause) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index 2c065918..fa5a37b8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -176,7 +176,7 @@ internal abstract class AbstractInAppViewHolder( bind() preparedImages[imageView] = true if (!preparedImages.values.contains(false)) { - mindboxLogI("In-app ${wrapper.inAppType.inAppId} shown") + this@AbstractInAppViewHolder.mindboxLogI("In-app ${wrapper.inAppType.inAppId} shown") wrapper.inAppActionCallbacks.onInAppShown.onShown() preparedImages.keys.forEach { it.isVisible = true } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt index 6a01ed8e..2200df2a 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppGlideImageLoaderImplTest.kt @@ -141,7 +141,7 @@ internal class InAppGlideImageLoaderImplTest { } runCurrent() - listenerSlot.captured.onLoadFailed(mockk(), null, null, false) + listenerSlot.captured.onLoadFailed(mockk(relaxed = true), null, null, false) runCurrent() job.join() diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt index 5f7dafb0..8da7aaec 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt @@ -10,12 +10,17 @@ import com.android.volley.TimeoutError import com.android.volley.VolleyError import com.bumptech.glide.load.HttpException import com.bumptech.glide.load.engine.GlideException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test import java.net.ConnectException import java.net.SocketTimeoutException import java.net.UnknownHostException +import kotlin.time.Duration.Companion.milliseconds internal class TrackingFailureExtensionTest { @@ -119,4 +124,16 @@ internal class TrackingFailureExtensionTest { val inAppError = InAppContentFetchingError(glideException) assertFalse(inAppError.shouldTrackImageDownloadError()) } + + @Test + fun `shouldTrackImageDownloadError returns false when cause is TimeoutCancellationException`() { + var inAppError: InAppContentFetchingError? = null + try { + runBlocking { withTimeout(0L.milliseconds) { Thread.sleep(100) } } + } catch (e: TimeoutCancellationException) { + inAppError = InAppContentFetchingError(e) + } + assertNotNull(inAppError) + assertFalse(inAppError!!.shouldTrackImageDownloadError()) + } } From 509c3434d7cd9c97b228cb29d7527284d03bee4c Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 4 Jun 2026 15:20:31 +0300 Subject: [PATCH 16/37] MOBILE-52: Fix test --- .../inapp/domain/extensions/TrackingFailureExtensionTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt index 8da7aaec..121d037d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtensionTest.kt @@ -11,6 +11,7 @@ import com.android.volley.VolleyError import com.bumptech.glide.load.HttpException import com.bumptech.glide.load.engine.GlideException import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.junit.Assert.assertFalse @@ -129,7 +130,7 @@ internal class TrackingFailureExtensionTest { fun `shouldTrackImageDownloadError returns false when cause is TimeoutCancellationException`() { var inAppError: InAppContentFetchingError? = null try { - runBlocking { withTimeout(0L.milliseconds) { Thread.sleep(100) } } + runBlocking { withTimeout(1.milliseconds) { delay(100.milliseconds) } } } catch (e: TimeoutCancellationException) { inAppError = InAppContentFetchingError(e) } From 5b00d74782a5b02cf89c7f5d838413fd845b5a99 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 9 Jun 2026 12:33:03 +0600 Subject: [PATCH 17/37] MOBILE-215: Add example.properties --- example/.gitignore | 1 + example/app/build.gradle | 17 ++++++++++++++--- example/app/proguard-rules.pro | 1 + example/app/src/main/AndroidManifest.xml | 2 +- .../com/mindbox/example/ExampleApplication.kt | 13 ++++++++++--- .../app/src/main/res/layout/activity_main.xml | 1 + 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/example/.gitignore b/example/.gitignore index 0e96b7e1..06055cc2 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -28,4 +28,5 @@ local.properties *.zip *.keystore keystore.properties +example.properties *.jks \ No newline at end of file diff --git a/example/app/build.gradle b/example/app/build.gradle index 18dd0238..ee1c679a 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -11,6 +11,12 @@ if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } +def examplePropertiesFile = rootProject.file("example.properties") +def exampleProperties = new Properties() +if (examplePropertiesFile.exists()) { + exampleProperties.load(new FileInputStream(examplePropertiesFile)) +} + android { namespace 'com.mindbox.example' compileSdk 35 @@ -22,6 +28,11 @@ android { versionCode 7 versionName "2.5" multiDexEnabled true + + buildConfigField "String", "MINDBOX_DOMAIN", "\"${exampleProperties.getProperty('mindbox.domain', '')}\"" + buildConfigField "String", "MINDBOX_ENDPOINT_ID", "\"${exampleProperties.getProperty('mindbox.endpointId', '')}\"" + buildConfigField "String", "MINDBOX_RUSTORE_PROJECT_ID", "\"${exampleProperties.getProperty('mindbox.ruStoreProjectId', '')}\"" + manifestPlaceholders = [ruStoreProjectId: exampleProperties.getProperty('mindbox.ruStoreProjectId', '')] } compileOptions { @@ -82,7 +93,7 @@ dependencies { implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - //Push services + // Push services implementation platform('com.google.firebase:firebase-bom:32.7.1') implementation 'com.google.firebase:firebase-analytics-ktx' implementation 'com.google.firebase:firebase-messaging-ktx' @@ -91,13 +102,13 @@ dependencies { implementation 'com.google.code.gson:gson:2.11.0' - //Mindbox + // Mindbox implementation 'cloud.mindbox:mobile-sdk:2.15.2' implementation 'cloud.mindbox:mindbox-firebase' implementation 'cloud.mindbox:mindbox-huawei' implementation 'cloud.mindbox:mindbox-rustore' - //Glade for custom loader + // Glide for custom loader implementation 'com.github.bumptech.glide:glide:4.15.1' implementation 'androidx.activity:activity-ktx:1.9.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.15.1' diff --git a/example/app/proguard-rules.pro b/example/app/proguard-rules.pro index d0e2a432..7303885a 100644 --- a/example/app/proguard-rules.pro +++ b/example/app/proguard-rules.pro @@ -32,5 +32,6 @@ -dontwarn com.huawei.libcore.io.ExternalStorageFileInputStream -dontwarn com.huawei.libcore.io.ExternalStorageFileOutputStream -dontwarn com.huawei.libcore.io.ExternalStorageRandomAccessFile +-dontwarn com.huawei.hms.availableupdate.UpdateAdapterMgr -keep class com.mindbox.example.PushPayload { *; } \ No newline at end of file diff --git a/example/app/src/main/AndroidManifest.xml b/example/app/src/main/AndroidManifest.xml index 5d2c43ec..9c3eecb2 100644 --- a/example/app/src/main/AndroidManifest.xml +++ b/example/app/src/main/AndroidManifest.xml @@ -64,7 +64,7 @@ + android:value="${ruStoreProjectId}" /> \ No newline at end of file diff --git a/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt b/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt index a267d82c..30ab39c9 100644 --- a/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt +++ b/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt @@ -12,7 +12,9 @@ import cloud.mindbox.mobile_sdk.logger.Level class ExampleApp : Application() { companion object { - const val RU_STORE_PROJECT_ID = "" //paste your RuStore project id + // Value is injected from example.properties via BuildConfig. + // To set it manually, replace BuildConfig.MINDBOX_RUSTORE_PROJECT_ID with a string literal. + val RU_STORE_PROJECT_ID: String = BuildConfig.MINDBOX_RUSTORE_PROJECT_ID //paste your RuStore project id private var privateApplication: Application? = null val application: Application get() = privateApplication!! @@ -23,10 +25,15 @@ class ExampleApp : Application() { privateApplication = this //https://developers.mindbox.ru/docs/android-sdk-initialization + // Values are injected from example.properties via BuildConfig. + // To set them manually, replace BuildConfig.MINDBOX_DOMAIN / MINDBOX_ENDPOINT_ID with string literals. + val domain = BuildConfig.MINDBOX_DOMAIN.ifEmpty { "" } //paste your domain address + val endpointId = BuildConfig.MINDBOX_ENDPOINT_ID.ifEmpty { "" } //paste your endpointId + val configuration = MindboxConfiguration.Builder( context = applicationContext, - domain = "",//paste your domain address - endpointId = ""//paste your domain address + domain = domain, + endpointId = endpointId ) .shouldCreateCustomer(true) .subscribeCustomerIfCreated(true) diff --git a/example/app/src/main/res/layout/activity_main.xml b/example/app/src/main/res/layout/activity_main.xml index 175b3333..67eaa34f 100644 --- a/example/app/src/main/res/layout/activity_main.xml +++ b/example/app/src/main/res/layout/activity_main.xml @@ -4,6 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:context=".MainActivity"> Date: Tue, 9 Jun 2026 13:12:00 +0600 Subject: [PATCH 18/37] MOBILE-215: Change Readme --- example/README.md | 24 +++++++++++++++--------- example/app/build.gradle | 8 ++++++-- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/example/README.md b/example/README.md index 2828f255..27c717f3 100644 --- a/example/README.md +++ b/example/README.md @@ -1,25 +1,31 @@ To run the example application with functioning mobile push notifications (complete only step 4 for in-app functionality to work), follow these steps: -1) Change the package identifier in the **app/build.gradle** file +1) Change the package identifier in the **app/build.gradle** file if needed 2) Add your application to either Firebase, Huawei, RuStore project, following the instructions provided at: -[Firebase Key Generation](https://developers.mindbox.ru/docs/firebase-get-keys) / +[Firebase Key Generation](https://developers.mindbox.ru/docs/firebase-get-keys) / [Huawei Key Generation](https://developers.mindbox.ru/docs/huawei-get-keys) / [RuStore Key Generation](https://developers.mindbox.ru/docs/rustore-get-keys) / or add app in your existing project 3) Configure Push Notification Services: * For Firebase: -Copy the **google-services.json** file into the app folder of your project. +Copy the **google-services.json** file into the **app/** folder of your project. * For Huawei: -Copy the **agcconnect-services.json** file into the app folder of your project. - -* For RuStore: -Change RU_STORE_PROJECT_ID in **app/src/main/AndroidManifest.xml**. Replacing RU_STORE_PROJECT_ID with your actual RuStore project ID. - -4) Set your domain and endpoint in the [ExampleApplication](https://github.com/mindbox-cloud/android-sdk/blob/develop/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt) class within the configuration builder +Copy the **agconnect-services.json** file into the **app/** folder of your project. + +4) Create **example.properties** in the root of the example project (next to `settings.gradle`). +This file is gitignored — fill in your own values: +``` +mindbox.domain=your.domain.here +mindbox.endpointId=your-endpoint-id +mindbox.ruStoreProjectId=your-rustore-project-id +``` +These values are injected into `BuildConfig` at build time and read by +[ExampleApplication](https://github.com/mindbox-cloud/android-sdk/blob/develop/example/app/src/main/java/com/mindbox/example/ExampleApplication.kt). +Alternatively, you can set the values directly in that file as string literals. 5) Run the application diff --git a/example/app/build.gradle b/example/app/build.gradle index ee1c679a..c29f4c67 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -8,13 +8,17 @@ plugins { def keystorePropertiesFile = rootProject.file("keystore.properties") def keystoreProperties = new Properties() if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + keystorePropertiesFile.withInputStream { stream -> + keystoreProperties.load(stream) + } } def examplePropertiesFile = rootProject.file("example.properties") def exampleProperties = new Properties() if (examplePropertiesFile.exists()) { - exampleProperties.load(new FileInputStream(examplePropertiesFile)) + examplePropertiesFile.withInputStream { stream -> + exampleProperties.load(stream) + } } android { From 261c24aa4a42115b7fc1cbc0ca96c3129fa27fc0 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 9 Jun 2026 13:25:37 +0600 Subject: [PATCH 19/37] MOBILE-215: Bump example versionName --- example/app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/app/build.gradle b/example/app/build.gradle index c29f4c67..47f8ba56 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -29,8 +29,8 @@ android { applicationId "com.mindbox.example" minSdk 21 targetSdk 35 - versionCode 7 - versionName "2.5" + versionCode 8 + versionName "2.15.2" multiDexEnabled true buildConfigField "String", "MINDBOX_DOMAIN", "\"${exampleProperties.getProperty('mindbox.domain', '')}\"" From 29953eb4825a50e89742eb7571539fbaeb9b3977 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:52:18 +0300 Subject: [PATCH 20/37] MOBILE-86: fix InApp under nested DialogFragment --- .../view/InAppPositionController.kt | 19 +++- .../view/InAppPositionControllerTest.kt | 88 +++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionControllerTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt index 7381f739..0bd70b68 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionController.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.ContextWrapper import android.view.View import android.view.ViewGroup +import androidx.annotation.VisibleForTesting import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity @@ -99,9 +100,21 @@ internal class InAppPositionController { } } - private fun findTopDialogFragment(fragmentManager: FragmentManager): DialogFragment? { - return fragmentManager.fragments.filterIsInstance().lastOrNull { it.isAdded } - } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun findTopDialogFragment(fragmentManager: FragmentManager): DialogFragment? = + fragmentManager.allDialogFragments() + .lastOrNull { it.isAdded && it.dialog?.window != null } + + private fun FragmentManager.allDialogFragments(): List = + fragments.flatMap { fragment -> + val self = if (fragment is DialogFragment) listOf(fragment) else emptyList() + val nested = if (fragment.isAdded) { + fragment.childFragmentManager.allDialogFragments() + } else { + emptyList() + } + self + nested + } private fun View.findActivity(): Activity? { var context = this.context diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionControllerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionControllerTest.kt new file mode 100644 index 00000000..3150741c --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/InAppPositionControllerTest.kt @@ -0,0 +1,88 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import android.app.Dialog +import android.view.Window +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +internal class InAppPositionControllerTest { + + private val controller = InAppPositionController() + + @Test + fun `finds dialog nested in child fragment manager`() { + val nestedDialog = dialogFragment() + val host = plainFragment(children = listOf(nestedDialog)) + val rootFm = fragmentManager(host) + + assertSame(nestedDialog, controller.findTopDialogFragment(rootFm)) + } + + @Test + fun `returns the top-most dialog, not the first one`() { + val first = dialogFragment() + val last = dialogFragment() + val rootFm = fragmentManager(first, last) + + assertSame(last, controller.findTopDialogFragment(rootFm)) + } + + @Test + fun `ignores dialog that is not added`() { + val added = dialogFragment(isAdded = true) + val notAdded = dialogFragment(isAdded = false) + val rootFm = fragmentManager(added, notAdded) + + assertSame(added, controller.findTopDialogFragment(rootFm)) + } + + @Test + fun `ignores dialog without a window`() { + val withWindow = dialogFragment(hasWindow = true) + val withoutWindow = dialogFragment(hasWindow = false) + val rootFm = fragmentManager(withWindow, withoutWindow) + + assertSame(withWindow, controller.findTopDialogFragment(rootFm)) + } + + @Test + fun `returns null when there are no dialogs`() { + val rootFm = fragmentManager(plainFragment(), plainFragment()) + + assertNull(controller.findTopDialogFragment(rootFm)) + } + + @Test + fun `returns null for an empty fragment manager`() { + assertNull(controller.findTopDialogFragment(fragmentManager())) + } + + private fun fragmentManager(vararg fragments: Fragment): FragmentManager = mockk { + every { this@mockk.fragments } returns fragments.toList() + } + + private fun plainFragment( + children: List = emptyList(), + isAdded: Boolean = true, + ): Fragment = mockk { + every { this@mockk.isAdded } returns isAdded + every { childFragmentManager } returns fragmentManager(*children.toTypedArray()) + } + + private fun dialogFragment( + isAdded: Boolean = true, + hasWindow: Boolean = true, + ): DialogFragment = mockk { + every { this@mockk.isAdded } returns isAdded + every { childFragmentManager } returns fragmentManager() + every { dialog } returns mockk { + every { window } returns if (hasWindow) mockk() else null + } + } +} From ff10cf0ffbbb5620d19ebfef6c7c2736d9dfb743 Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:59:46 +0300 Subject: [PATCH 21/37] MOBILE-121: Bump deprecated actions --- .github/bump_versions_in_release_branch.sh | 20 +-- .github/dependabot.yml | 16 +++ .github/git-release-ci.sh | 24 ++-- .../workflows/distribute-develop-mission.yml | 6 +- .github/workflows/distribute-manual.yml | 6 +- .../distribute-release-support-mission.yml | 6 +- .github/workflows/distribute-reusable.yml | 14 +- .github/workflows/lint_unitTests_build.yml | 55 +++++--- .../manual-prepare_release_branch.yml | 45 +++++-- .github/workflows/prepare_release_branch.yml | 46 ++++--- .../publish-from-master-or-support.yml | 14 +- .github/workflows/publish-manual.yml | 18 ++- .github/workflows/publish-reusable.yml | 124 ++++++++++-------- .github/workflows/release-version-check.yml | 17 ++- 14 files changed, 276 insertions(+), 135 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/bump_versions_in_release_branch.sh b/.github/bump_versions_in_release_branch.sh index 68ed1d1f..af27bc8e 100755 --- a/.github/bump_versions_in_release_branch.sh +++ b/.github/bump_versions_in_release_branch.sh @@ -1,4 +1,5 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail # Check if the parameter is provided if [ $# -eq 0 ]; then @@ -31,19 +32,22 @@ ls -l # Add changelog to the index and create a commit properties_file="gradle.properties" current_version=$(grep -E '^SDK_VERSION_NAME=' gradle.properties | cut -d'=' -f2) -sed -i "s/^SDK_VERSION_NAME=.*/SDK_VERSION_NAME=$version/" $properties_file -sed -i "s/^SDK_VERSION_NAME=.*/SDK_VERSION_NAME=$version/" $properties_file +sed -i "s/^SDK_VERSION_NAME=.*/SDK_VERSION_NAME=$version/" "$properties_file" build_gradle_example_path="example/app/build.gradle" -sed -i -E "s/cloud.mindbox:mobile-sdk:[0-9]+\.[0-9]+\.[0-9]+(-rc)?/cloud.mindbox:mobile-sdk:$version/" $build_gradle_example_path +sed -i -E "s/cloud.mindbox:mobile-sdk:[0-9]+\.[0-9]+\.[0-9]+(-rc)?/cloud.mindbox:mobile-sdk:$version/" "$build_gradle_example_path" echo "Bump SDK version from $current_version to $version." -git add $properties_file -git add -f $build_gradle_example_path -git commit -m "Bump SDK version to $version" +git add "$properties_file" +git add -f "$build_gradle_example_path" +if git diff --cached --quiet; then + echo "Version is already $version — nothing to commit." +else + git commit -m "Bump SDK version to $version" +fi echo "Pushing changes to branch: $current_branch" -if ! git push origin $current_branch; then +if ! git push origin "$current_branch"; then echo "Failed to push changes to the origin $current_branch" exit 1 fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..e8b6f5ed --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + open-pull-requests-limit: 1 + groups: + github-actions: + patterns: ["*"] + commit-message: + prefix: "deps" + include: "scope" diff --git a/.github/git-release-ci.sh b/.github/git-release-ci.sh index 8c004fdf..6c42dbd1 100755 --- a/.github/git-release-ci.sh +++ b/.github/git-release-ci.sh @@ -1,5 +1,5 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail version=$(grep '^SDK_VERSION_NAME=' gradle.properties | cut -d '=' -f2) @@ -8,7 +8,8 @@ if [[ $version == *"rc"* ]]; then is_beta=true fi -branch=${GITHUB_REF#refs/heads/} +branch=${RELEASE_BRANCH:-${GITHUB_REF:-}} +branch=${branch#refs/heads/} echo "Version: $version" echo "Is Beta: $is_beta" @@ -17,29 +18,32 @@ echo "Branch: $branch" echo "Fetching tags from remote repository." git fetch --tags -if git rev-parse "$version" >/dev/null 2>&1; then +if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then echo "Local tag $version already exists." else echo "Creating local tag $version." - git tag $version + git tag "$version" fi -if git ls-remote --tags origin | grep -q "refs/tags/$version"; then +if [ -n "$(git ls-remote --tags origin "refs/tags/$version")" ]; then echo "Remote tag $version already exists." else echo "Pushing local tag $version to remote." - git push origin $version + git push origin "$version" fi # Create release release_title="Release $version" -text="Autogenerated release. Check more details at [Mindbox SDK Documentation](https://developers.mindbox.ru/docs/androidhuawei-native-sdk)" +text="Autogenerated release. Check more details at [Mindbox SDK Documentation](https://developers.mindbox.ru/docs/android-sdk)" echo "Release title: $release_title" echo "Text: $text" -prerelease_flag=$([ "$is_beta" = true ] && echo "--prerelease" || echo "") +release_args=("$version" --target "$branch" --title "$release_title" --notes "$text") +if [ "$is_beta" = true ]; then + release_args+=(--prerelease) +fi -gh release create "$version" --target "$branch" --title "$release_title" --notes "$text" $prerelease_flag +gh release create "${release_args[@]}" echo "Release $version created for repo on branch $branch" diff --git a/.github/workflows/distribute-develop-mission.yml b/.github/workflows/distribute-develop-mission.yml index d8fb904a..05e36c56 100644 --- a/.github/workflows/distribute-develop-mission.yml +++ b/.github/workflows/distribute-develop-mission.yml @@ -8,10 +8,14 @@ on: types: - closed +permissions: + contents: read + jobs: call-reusable: if: ${{ github.event.pull_request.merged == true }} uses: ./.github/workflows/distribute-reusable.yml with: branch: ${{ github.base_ref }} - secrets: inherit + secrets: + GITLAB_TRIGGER_TOKEN: ${{ secrets.GITLAB_TRIGGER_TOKEN }} diff --git a/.github/workflows/distribute-manual.yml b/.github/workflows/distribute-manual.yml index b0341fbb..f4593cd0 100644 --- a/.github/workflows/distribute-manual.yml +++ b/.github/workflows/distribute-manual.yml @@ -8,10 +8,14 @@ on: required: false default: "" +permissions: + contents: read + jobs: call-reusable: uses: ./.github/workflows/distribute-reusable.yml with: branch: ${{ github.ref_name }} app_ref: ${{ inputs.app_ref }} - secrets: inherit + secrets: + GITLAB_TRIGGER_TOKEN: ${{ secrets.GITLAB_TRIGGER_TOKEN }} diff --git a/.github/workflows/distribute-release-support-mission.yml b/.github/workflows/distribute-release-support-mission.yml index e3fa9265..bcca01cf 100644 --- a/.github/workflows/distribute-release-support-mission.yml +++ b/.github/workflows/distribute-release-support-mission.yml @@ -10,10 +10,14 @@ on: - opened - synchronize +permissions: + contents: read + jobs: call-reusable: if: ${{ startsWith(github.event.pull_request.head.ref, 'release/') }} uses: ./.github/workflows/distribute-reusable.yml - secrets: inherit + secrets: + GITLAB_TRIGGER_TOKEN: ${{ secrets.GITLAB_TRIGGER_TOKEN }} with: branch: ${{ github.event.pull_request.head.ref }} diff --git a/.github/workflows/distribute-reusable.yml b/.github/workflows/distribute-reusable.yml index 229fd15f..a27f524d 100644 --- a/.github/workflows/distribute-reusable.yml +++ b/.github/workflows/distribute-reusable.yml @@ -14,12 +14,15 @@ on: GITLAB_TRIGGER_TOKEN: required: true +permissions: + contents: read + jobs: distribution: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.branch }} submodules: recursive @@ -30,9 +33,12 @@ jobs: run: | set -euo pipefail commits="$(git log -3 --pretty=format:"%s")" - echo "commits<> "$GITHUB_ENV" - echo "$commits" >> "$GITHUB_ENV" - echo "EOF" >> "$GITHUB_ENV" + delim="EOF_$(openssl rand -hex 8)" + { + echo "commits<<$delim" + echo "$commits" + echo "$delim" + } >> "$GITHUB_ENV" - name: Debug payload that will be sent to GitLab shell: bash diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index a1a3cc00..54eea3d4 100644 --- a/.github/workflows/lint_unitTests_build.yml +++ b/.github/workflows/lint_unitTests_build.yml @@ -18,34 +18,48 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - fetch-depth: 2 + fetch-depth: 0 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: Get changed Kotlin files id: get_changed_files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.sha }} run: | - CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -E '\.kt$|\.kts$' || true) + set -euo pipefail + if [ -z "$BASE_SHA" ] || ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "Base commit '$BASE_SHA' unavailable; falling back to ${HEAD_SHA}^" + BASE_SHA=$(git rev-parse "${HEAD_SHA}^") + fi + echo "Diff range: $BASE_SHA..$HEAD_SHA" + CHANGED_FILES=$(git diff --name-only --diff-filter=d "$BASE_SHA" "$HEAD_SHA" | { grep -E '\.kt$|\.kts$' || true; }) echo "Changed Kotlin files:" echo "$CHANGED_FILES" - echo "CHANGED_FILES<> $GITHUB_OUTPUT - echo "$CHANGED_FILES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + DELIM="EOF_$(openssl rand -hex 8)" + { + echo "CHANGED_FILES<<$DELIM" + echo "$CHANGED_FILES" + echo "$DELIM" + } >> "$GITHUB_OUTPUT" - name: Run ktlintCheck on changed files if: steps.get_changed_files.outputs.CHANGED_FILES != '' - run: ./gradlew ktlintCheck -PinternalKtlintGitFilter="${{ steps.get_changed_files.outputs.CHANGED_FILES }}" + env: + CHANGED_FILES: ${{ steps.get_changed_files.outputs.CHANGED_FILES }} + run: ./gradlew ktlintCheck -PinternalKtlintGitFilter="$CHANGED_FILES" - name: lint check run: ./gradlew --no-daemon lintDebug @@ -54,31 +68,36 @@ jobs: run: ./gradlew --no-daemon detektDebug unit: - runs-on: ubuntu-latest + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: unit tests with coverage run: ./gradlew --no-daemon --stacktrace testDebugUnitTest koverHtmlReport - name: test report - uses: asadmansr/android-test-report-action@v1.2.0 + uses: mikepenz/action-junit-report@3a81627bfac62268172037048872e8ebd4207e6d # v6.4.1 if: ${{ always() }} + with: + report_paths: '**/build/test-results/testDebugUnitTest/TEST-*.xml' + fail_on_failure: true + detailed_summary: true + annotate_only: true - name: upload coverage report - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 if: github.ref == 'refs/heads/develop' with: path: build/reports/kover/html @@ -96,17 +115,17 @@ jobs: steps: - name: deploy to GitHub Pages id: deploy - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/manual-prepare_release_branch.yml b/.github/workflows/manual-prepare_release_branch.yml index e0a18073..f38313eb 100644 --- a/.github/workflows/manual-prepare_release_branch.yml +++ b/.github/workflows/manual-prepare_release_branch.yml @@ -15,14 +15,19 @@ on: required: true default: 'master' +permissions: + contents: read + jobs: validate-input: name: Validate release_version format runs-on: ubuntu-latest steps: - name: Check version matches semver or semver-rc + env: + RELEASE_VERSION: ${{ github.event.inputs.release_version }} run: | - VER="${{ github.event.inputs.release_version }}" + VER="$RELEASE_VERSION" echo "→ release_version = $VER" if ! [[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc)?$ ]]; then echo "❌ release_version must be X.Y.Z or X.Y.Z-rc" @@ -35,22 +40,26 @@ jobs: needs: validate-input steps: - name: Checkout minimal repo - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 submodules: recursive - name: Validate source branch exists + env: + SOURCE_BRANCH: ${{ github.event.inputs.source_branch }} run: | - SRC="${{ github.event.inputs.source_branch }}" + SRC="$SOURCE_BRANCH" if ! git ls-remote --heads origin "$SRC" | grep -q "$SRC"; then echo "❌ source_branch '$SRC' does not exist on origin" exit 1 fi - name: Validate target branch exists + env: + TARGET_BRANCH: ${{ github.event.inputs.target_branch }} run: | - DST="${{ github.event.inputs.target_branch }}" + DST="$TARGET_BRANCH" if ! git ls-remote --heads origin "$DST" | grep -q "$DST"; then echo "❌ target_branch '$DST' does not exist on origin" exit 1 @@ -60,11 +69,13 @@ jobs: name: Create release branch & bump version runs-on: ubuntu-latest needs: validate-branches + permissions: + contents: write outputs: release_branch: ${{ steps.bump.outputs.release_branch }} steps: - name: Checkout source branch - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.inputs.source_branch }} fetch-depth: 0 @@ -77,9 +88,12 @@ jobs: - name: Create release branch & bump version id: bump + env: + RELEASE_VERSION: ${{ github.event.inputs.release_version }} + SOURCE_BRANCH: ${{ github.event.inputs.source_branch }} run: | - VERSION="${{ github.event.inputs.release_version }}" - SRC="${{ github.event.inputs.source_branch }}" + VERSION="$RELEASE_VERSION" + SRC="$SOURCE_BRANCH" REL="release/$VERSION" echo "→ Branching from $SRC into $REL" @@ -88,11 +102,13 @@ jobs: echo "→ Running bump script on $REL" ./.github/bump_versions_in_release_branch.sh "$VERSION" - echo "release_branch=$REL" >> $GITHUB_OUTPUT + echo "release_branch=$REL" >> "$GITHUB_OUTPUT" - name: Push release branch + env: + RELEASE_BRANCH: ${{ steps.bump.outputs.release_branch }} run: | - git push origin "${{ steps.bump.outputs.release_branch }}" + git push origin "$RELEASE_BRANCH" check_sdk_version: name: Check SDK Version @@ -100,7 +116,7 @@ jobs: needs: bump_and_branch steps: - name: Checkout the release branch - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.bump_and_branch.outputs.release_branch }} fetch-depth: 0 @@ -110,8 +126,10 @@ jobs: run: git pull - name: Validate sdkVersion + env: + RELEASE_VERSION: ${{ github.event.inputs.release_version }} run: | - EXPECT="${{ github.event.inputs.release_version }}" + EXPECT="$RELEASE_VERSION" SDK_VERSION=$(grep -E '^SDK_VERSION_NAME=' gradle.properties | cut -d'=' -f2) echo "→ Found in code: $SDK_VERSION, expected: $EXPECT" if [ "$SDK_VERSION" != "$EXPECT" ]; then @@ -130,11 +148,12 @@ jobs: SRC: ${{ needs.bump_and_branch.outputs.release_branch }} DST: ${{ github.event.inputs.target_branch }} REPO: ${{ github.repository }} + RELEASE_VERSION: ${{ github.event.inputs.release_version }} run: | echo "→ Creating PR from $SRC into $DST" gh pr create \ --repo "$REPO" \ --base "$DST" \ --head "$SRC" \ - --title "Release ${{ github.event.inputs.release_version }}" \ - --body "Updates the release version to ${{ github.event.inputs.release_version }}. Automated PR: merge $SRC into $DST" \ No newline at end of file + --title "Release $RELEASE_VERSION" \ + --body "Updates the release version to $RELEASE_VERSION. Automated PR: merge $SRC into $DST" \ No newline at end of file diff --git a/.github/workflows/prepare_release_branch.yml b/.github/workflows/prepare_release_branch.yml index 4cbf2f83..fff6ac7b 100644 --- a/.github/workflows/prepare_release_branch.yml +++ b/.github/workflows/prepare_release_branch.yml @@ -6,6 +6,9 @@ on: - 'release/*.*.*' - 'support/*.*.*' +permissions: + contents: read + jobs: extract_version: if: github.event.created @@ -16,23 +19,27 @@ jobs: steps: - name: Extract version from branch name id: extract + env: + REF_NAME: ${{ github.ref_name }} run: | - BRANCH_NAME="${{ github.ref_name }}" + BRANCH_NAME="$REF_NAME" echo "BRANCH_NAME: $BRANCH_NAME" VERSION="${BRANCH_NAME#release/}" VERSION="${VERSION#support/}" echo "VERSION: $VERSION" - echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" bump_version: name: Bump Version runs-on: ubuntu-latest needs: extract_version + permissions: + contents: write outputs: version2: ${{ steps.bump.outputs.version2 }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: recursive @@ -42,12 +49,16 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Bump version - run: ./.github/bump_versions_in_release_branch.sh "${{ needs.extract_version.outputs.version }}" + env: + VERSION: ${{ needs.extract_version.outputs.version }} + run: ./.github/bump_versions_in_release_branch.sh "$VERSION" - - name: Ouput version + - name: Output version id: bump + env: + VERSION: ${{ needs.extract_version.outputs.version }} run: | - echo "version2=${{ needs.extract_version.outputs.version }}" >> $GITHUB_OUTPUT + echo "version2=$VERSION" >> "$GITHUB_OUTPUT" check_sdk_version: name: Check SDK Version @@ -55,7 +66,7 @@ jobs: needs: bump_version steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 submodules: recursive @@ -64,12 +75,14 @@ jobs: run: git pull - name: Check if SDK_VERSION_NAME matches VERSION + env: + VERSION2: ${{ needs.bump_version.outputs.version2 }} run: | SDK_VERSION=$(grep '^SDK_VERSION_NAME=' gradle.properties | cut -d '=' -f2) - EXPECTED_VERSION=${{ needs.bump_version.outputs.version2 }} + EXPECTED_VERSION="$VERSION2" if [ "$SDK_VERSION" != "$EXPECTED_VERSION" ]; then - echo "SDK_VERSION_NAME ($ACTUAL_VERSION) does not match the expected version ($EXPECTED_VERSION)." + echo "SDK_VERSION_NAME ($SDK_VERSION) does not match the expected version ($EXPECTED_VERSION)." exit 1 fi shell: bash @@ -80,19 +93,20 @@ jobs: needs: check_sdk_version steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: recursive - name: Create Pull Request + env: + GH_TOKEN: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} + REF_NAME: ${{ github.ref_name }} run: | gh pr create \ --base master \ - --head ${{ github.ref_name }} \ - --title "${{ github.ref_name }}" \ - --body "Updates the release version to ${{ github.ref_name }}" + --head "$REF_NAME" \ + --title "$REF_NAME" \ + --body "Updates the release version to $REF_NAME" PR_URL=$(gh pr view --json url --jq '.url') - echo "PR_URL=$PR_URL" >> $GITHUB_ENV - env: - GH_TOKEN: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} + echo "PR_URL=$PR_URL" >> "$GITHUB_ENV" diff --git a/.github/workflows/publish-from-master-or-support.yml b/.github/workflows/publish-from-master-or-support.yml index a536cec3..f60e6957 100644 --- a/.github/workflows/publish-from-master-or-support.yml +++ b/.github/workflows/publish-from-master-or-support.yml @@ -7,10 +7,22 @@ on: - 'master' - 'support/*' +permissions: + contents: read + jobs: call-reusable: if: ${{ github.event.pull_request.merged == true }} + permissions: + contents: write uses: ./.github/workflows/publish-reusable.yml with: branch: ${{ github.base_ref }} - secrets: inherit \ No newline at end of file + secrets: + GPGKEYCONTENTS: ${{ secrets.GPGKEYCONTENTS }} + SECRETPASSPHRASE: ${{ secrets.SECRETPASSPHRASE }} + SIGNINGPASSWORD: ${{ secrets.SIGNINGPASSWORD }} + SIGNINGKEYID: ${{ secrets.SIGNINGKEYID }} + CENTER_PORTAL_USERNAME: ${{ secrets.CENTER_PORTAL_USERNAME }} + CENTER_PORTAL_PASSWORD: ${{ secrets.CENTER_PORTAL_PASSWORD }} + PAT_FOR_TRIGGERING_BRANCH_PROTECTION: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} \ No newline at end of file diff --git a/.github/workflows/publish-manual.yml b/.github/workflows/publish-manual.yml index 7277c775..6c15a81d 100644 --- a/.github/workflows/publish-manual.yml +++ b/.github/workflows/publish-manual.yml @@ -4,20 +4,34 @@ name: SDK publish RC manual on: workflow_dispatch: +permissions: + contents: read + jobs: check-branch: runs-on: ubuntu-latest steps: - name: Check if branch matches pattern + env: + REF_NAME: ${{ github.ref_name }} run: | - if ! echo "${{ github.ref_name }}" | grep -q "release/.*-rc"; then + if ! echo "$REF_NAME" | grep -q "release/.*-rc"; then echo "Branch name must match pattern 'release/*-rc' (e.g. release/2.13.2-rc)" exit 1 fi call-publish-reusable: needs: check-branch + permissions: + contents: write uses: ./.github/workflows/publish-reusable.yml with: branch: ${{ github.ref_name }} - secrets: inherit + secrets: + GPGKEYCONTENTS: ${{ secrets.GPGKEYCONTENTS }} + SECRETPASSPHRASE: ${{ secrets.SECRETPASSPHRASE }} + SIGNINGPASSWORD: ${{ secrets.SIGNINGPASSWORD }} + SIGNINGKEYID: ${{ secrets.SIGNINGKEYID }} + CENTER_PORTAL_USERNAME: ${{ secrets.CENTER_PORTAL_USERNAME }} + CENTER_PORTAL_PASSWORD: ${{ secrets.CENTER_PORTAL_PASSWORD }} + PAT_FOR_TRIGGERING_BRANCH_PROTECTION: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} diff --git a/.github/workflows/publish-reusable.yml b/.github/workflows/publish-reusable.yml index ef7cd19c..69fbb2df 100644 --- a/.github/workflows/publish-reusable.yml +++ b/.github/workflows/publish-reusable.yml @@ -6,43 +6,61 @@ on: branch: required: true type: string - + secrets: + GPGKEYCONTENTS: + required: true + SECRETPASSPHRASE: + required: true + SIGNINGPASSWORD: + required: true + SIGNINGKEYID: + required: true + CENTER_PORTAL_USERNAME: + required: true + CENTER_PORTAL_PASSWORD: + required: true + PAT_FOR_TRIGGERING_BRANCH_PROTECTION: + required: true + +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: lint check run: ./gradlew --no-daemon lintDebug unit-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 - name: unit tests run: ./gradlew --no-daemon testDebugUnitTest @@ -50,13 +68,13 @@ jobs: needs: [lint, unit-tests] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: 'temurin' java-version: '17' @@ -67,58 +85,73 @@ jobs: set-tag: needs: [build] runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.branch }} submodules: recursive - name: Extract SDK version run: | SDK_VERSION=$(grep '^SDK_VERSION_NAME=' gradle.properties | cut -d '=' -f2) - echo "SDK_VERSION=$SDK_VERSION" >> $GITHUB_ENV + echo "SDK_VERSION=$SDK_VERSION" >> "$GITHUB_ENV" echo "Extracted SDK version: $SDK_VERSION" - name: Create tag run: | - git tag ${{ env.SDK_VERSION }} - git push origin ${{ env.SDK_VERSION }} + if git rev-parse -q --verify "refs/tags/$SDK_VERSION" >/dev/null; then + echo "Local tag $SDK_VERSION already exists." + else + git tag "$SDK_VERSION" + fi + if [ -n "$(git ls-remote --tags origin "refs/tags/$SDK_VERSION")" ]; then + echo "Remote tag $SDK_VERSION already exists." + else + git push origin "$SDK_VERSION" + fi publish: needs: [set-tag] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - name: Prepare to publish + env: + GPGKEYCONTENTS: ${{ secrets.GPGKEYCONTENTS }} + SECRETPASSPHRASE: ${{ secrets.SECRETPASSPHRASE }} + SIGNINGPASSWORD: ${{ secrets.SIGNINGPASSWORD }} + SIGNINGKEYID: ${{ secrets.SIGNINGKEYID }} + CENTER_PORTAL_USERNAME: ${{ secrets.CENTER_PORTAL_USERNAME }} + CENTER_PORTAL_PASSWORD: ${{ secrets.CENTER_PORTAL_PASSWORD }} run: | - echo '${{secrets.GPGKEYCONTENTS}}' | base64 -d > /tmp/publish_key.gpg - gpg --quiet --batch --yes --decrypt --passphrase="${{secrets.SECRETPASSPHRASE}}" \ + echo "$GPGKEYCONTENTS" | base64 -d > /tmp/publish_key.gpg + gpg --quiet --batch --yes --decrypt --passphrase="$SECRETPASSPHRASE" \ --output /tmp/secret.gpg /tmp/publish_key.gpg - echo "" >> gradle.properties - echo "signing.password=${{secrets.SIGNINGPASSWORD}}" >> gradle.properties - echo "signing.keyId=${{secrets.SIGNINGKEYID}}" >> gradle.properties - echo "signing.secretKeyRingFile=/tmp/secret.gpg" >> gradle.properties - echo "mavenCentralUsername=${{secrets.CENTER_PORTAL_USERNAME}}" >> gradle.properties - echo "mavenCentralPassword=${{secrets.CENTER_PORTAL_PASSWORD}}" >> gradle.properties + { + echo "" + echo "signing.password=$SIGNINGPASSWORD" + echo "signing.keyId=$SIGNINGKEYID" + echo "signing.secretKeyRingFile=/tmp/secret.gpg" + echo "mavenCentralUsername=$CENTER_PORTAL_USERNAME" + echo "mavenCentralPassword=$CENTER_PORTAL_PASSWORD" + } >> gradle.properties + - name: Publish to Central Portal + # Change github variable CENTER_PORTAL_AUTO_RELEASE to set up auto release Maven Central env: - signingpassword: ${{secrets.SIGNINGPASSWORD}} - signingkeyId: ${{secrets.SIGNINGKEYID}} - SECRETPASSPHRASE: ${{secrets.SECRETPASSPHRASE}} - GPGKEYCONTENTS: ${{secrets.GPGKEYCONTENTS}} + CENTER_PORTAL_AUTO_RELEASE: ${{ vars.CENTER_PORTAL_AUTO_RELEASE }} SONATYPE_CONNECT_TIMEOUT_SECONDS: 60 SONATYPE_CLOSE_TIMEOUT_SECONDS: 900 - ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' - - name: Publish to Central Portal - # Change github variable CENTER_PORTAL_AUTO_RELEASE to set up auto release Maven Central run: | - if [ "${{ vars.CENTER_PORTAL_AUTO_RELEASE }}" = "true" ]; then + if [ "$CENTER_PORTAL_AUTO_RELEASE" = "true" ]; then ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache else ./gradlew publishToMavenCentral --no-configuration-cache @@ -127,14 +160,17 @@ jobs: release-github: needs: [publish] runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.branch }} - - name: Github Release generation + - name: GitHub Release generation run: ./.github/git-release-ci.sh env: GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ inputs.branch }} merge: needs: [publish] @@ -146,7 +182,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} steps: - name: Checkout develop branch - uses: actions/checkout@v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: develop - name: Create Pull Request @@ -154,22 +190,4 @@ jobs: - name: Merge Pull Request run: | pr_number=$(gh pr list --base develop --head master --json number --jq '.[0].number') - gh pr merge $pr_number --merge --auto - - message-to-loop-if-success: - needs: [release-github] - runs-on: ubuntu-latest - steps: - - name: Send message to LOOP - env: - LOOP_NOTIFICATION_WEBHOOK_URL: ${{ secrets.LOOP_NOTIFICATION_WEBHOOK_URL }} - VERSION: ${{ github.ref_name }} - run: | - MESSAGE=$(cat <> $GITHUB_ENV - echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV + echo "MASTER_VERSION=$MASTER_VERSION" >> "$GITHUB_ENV" + echo "RELEASE_VERSION=$RELEASE_VERSION" >> "$GITHUB_ENV" - name: Compare versions - uses: jackbilestech/semver-compare@1.0.4 + uses: jackbilestech/semver-compare@b6b063c569b77bea4a0ab627192cbdabf75de3f5 # 1.0.4 with: head: ${{ env.RELEASE_VERSION }} base: ${{ env.MASTER_VERSION }} From 481037f1bf24a00b5acf4cde3fb474a0be4d6baf Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:27:43 +0300 Subject: [PATCH 22/37] MOBILE-0000: add gitleaks action --- .../workflows/gitleaks-secrets-validate.yml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/gitleaks-secrets-validate.yml diff --git a/.github/workflows/gitleaks-secrets-validate.yml b/.github/workflows/gitleaks-secrets-validate.yml new file mode 100644 index 00000000..31312bc2 --- /dev/null +++ b/.github/workflows/gitleaks-secrets-validate.yml @@ -0,0 +1,23 @@ +name: Gitleaks Secrets Validate +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + scan: + name: gitleaks + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + + - name: Run Gitleaks + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.MINDBOX_GITLEAKS_LICENSE }} From a23361e807670ad86ff00eef6aff208c7c8b0377 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:00:09 +0300 Subject: [PATCH 23/37] deps(deps): bump the github-actions group with 3 updates (#730) Bumps the github-actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [actions/setup-java](https://github.com/actions/setup-java) and [mikepenz/action-junit-report](https://github.com/mikepenz/action-junit-report). Updates `actions/checkout` from 6.0.3 to 7.0.0 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) Updates `actions/setup-java` from 5.2.0 to 5.3.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/be666c2fcd27ec809703dec50e508c2fdc7f6654...ad2b38190b15e4d6bdf0c97fb4fca8412226d287) Updates `mikepenz/action-junit-report` from 6.4.1 to 6.4.2 - [Release notes](https://github.com/mikepenz/action-junit-report/releases) - [Commits](https://github.com/mikepenz/action-junit-report/compare/3a81627bfac62268172037048872e8ebd4207e6d...d9f48fc87bc235f7e214acf696ca5abc0a986f16) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-java dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: mikepenz/action-junit-report dependency-version: 6.4.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/distribute-reusable.yml | 2 +- .../workflows/gitleaks-secrets-validate.yml | 2 +- .github/workflows/lint_unitTests_build.yml | 14 ++++++------ .../manual-prepare_release_branch.yml | 6 ++--- .github/workflows/prepare_release_branch.yml | 6 ++--- .github/workflows/publish-reusable.yml | 22 +++++++++---------- .github/workflows/release-version-check.yml | 4 ++-- 7 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/distribute-reusable.yml b/.github/workflows/distribute-reusable.yml index a27f524d..41289c90 100644 --- a/.github/workflows/distribute-reusable.yml +++ b/.github/workflows/distribute-reusable.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive diff --git a/.github/workflows/gitleaks-secrets-validate.yml b/.github/workflows/gitleaks-secrets-validate.yml index 31312bc2..b77c864c 100644 --- a/.github/workflows/gitleaks-secrets-validate.yml +++ b/.github/workflows/gitleaks-secrets-validate.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index 54eea3d4..6454e806 100644 --- a/.github/workflows/lint_unitTests_build.yml +++ b/.github/workflows/lint_unitTests_build.yml @@ -18,13 +18,13 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: true - name: Set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '17' @@ -70,12 +70,12 @@ jobs: unit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '17' @@ -88,7 +88,7 @@ jobs: run: ./gradlew --no-daemon --stacktrace testDebugUnitTest koverHtmlReport - name: test report - uses: mikepenz/action-junit-report@3a81627bfac62268172037048872e8ebd4207e6d # v6.4.1 + uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6.4.2 if: ${{ always() }} with: report_paths: '**/build/test-results/testDebugUnitTest/TEST-*.xml' @@ -120,12 +120,12 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/manual-prepare_release_branch.yml b/.github/workflows/manual-prepare_release_branch.yml index f38313eb..6bb8c661 100644 --- a/.github/workflows/manual-prepare_release_branch.yml +++ b/.github/workflows/manual-prepare_release_branch.yml @@ -40,7 +40,7 @@ jobs: needs: validate-input steps: - name: Checkout minimal repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: recursive @@ -75,7 +75,7 @@ jobs: release_branch: ${{ steps.bump.outputs.release_branch }} steps: - name: Checkout source branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.inputs.source_branch }} fetch-depth: 0 @@ -116,7 +116,7 @@ jobs: needs: bump_and_branch steps: - name: Checkout the release branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.bump_and_branch.outputs.release_branch }} fetch-depth: 0 diff --git a/.github/workflows/prepare_release_branch.yml b/.github/workflows/prepare_release_branch.yml index fff6ac7b..07eea0da 100644 --- a/.github/workflows/prepare_release_branch.yml +++ b/.github/workflows/prepare_release_branch.yml @@ -39,7 +39,7 @@ jobs: version2: ${{ steps.bump.outputs.version2 }} steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: recursive @@ -66,7 +66,7 @@ jobs: needs: bump_version steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: recursive @@ -93,7 +93,7 @@ jobs: needs: check_sdk_version steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: recursive diff --git a/.github/workflows/publish-reusable.yml b/.github/workflows/publish-reusable.yml index 69fbb2df..d3d618e0 100644 --- a/.github/workflows/publish-reusable.yml +++ b/.github/workflows/publish-reusable.yml @@ -29,13 +29,13 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '17' @@ -48,13 +48,13 @@ jobs: unit-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '17' @@ -68,13 +68,13 @@ jobs: needs: [lint, unit-tests] runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '17' @@ -88,7 +88,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive @@ -114,12 +114,12 @@ jobs: needs: [set-tag] runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 with: distribution: 'temurin' java-version: '17' @@ -163,7 +163,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.branch }} - name: GitHub Release generation @@ -182,7 +182,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.PAT_FOR_TRIGGERING_BRANCH_PROTECTION }} steps: - name: Checkout develop branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: develop - name: Create Pull Request diff --git a/.github/workflows/release-version-check.yml b/.github/workflows/release-version-check.yml index f20917ff..4a125628 100644 --- a/.github/workflows/release-version-check.yml +++ b/.github/workflows/release-version-check.yml @@ -30,13 +30,13 @@ jobs: if: github.base_ref == 'master' && startsWith(github.head_ref, 'release') steps: - name: Checkout master branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: master path: master submodules: recursive - name: Checkout release branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.head_ref }} path: release From 71d25debe3cd5079e5eb3b9d9ebd54bee5bfa5cf Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:35:10 +0300 Subject: [PATCH 24/37] MOBILE-220: add named firebase instance --- .../FirebaseServiceHandler.kt | 53 +++- .../src/main/res/values/strings.xml | 10 + .../FirebaseServiceHandlerTest.kt | 272 ++++++++++++++++++ .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 2 +- 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 mindbox-firebase/src/main/res/values/strings.xml create mode 100644 mindbox-firebase/src/test/kotlin/cloud/mindbox/mindbox_firebase/FirebaseServiceHandlerTest.kt diff --git a/mindbox-firebase/src/main/java/cloud/mindbox/mindbox_firebase/FirebaseServiceHandler.kt b/mindbox-firebase/src/main/java/cloud/mindbox/mindbox_firebase/FirebaseServiceHandler.kt index 25c7763d..cd1ea8bc 100644 --- a/mindbox-firebase/src/main/java/cloud/mindbox/mindbox_firebase/FirebaseServiceHandler.kt +++ b/mindbox-firebase/src/main/java/cloud/mindbox/mindbox_firebase/FirebaseServiceHandler.kt @@ -19,16 +19,44 @@ internal class FirebaseServiceHandler( private val exceptionHandler: ExceptionHandler, ) : PushServiceHandler() { + @Volatile + private var cachedNamedAppName: String? = null + override val notificationProvider: String = MindboxFirebase.tag override suspend fun initService(context: Context) { FirebaseApp.initializeApp(context) + createNamedAppIfConfigured(context) + } + + /** + * If the host app configured a named FirebaseApp via the `mindbox_firebase_app_name` + * string resource, makes sure it exists — creating it from the `[DEFAULT]` app's options (the same + * Firebase project), or reusing one the client already created. This gives Mindbox + * an FCM token isolated from `[DEFAULT]`, whose token third-party SDKs may churn. + * Does nothing when no named app is configured. + */ + private fun createNamedAppIfConfigured(context: Context) { + val appName = namedFirebaseAppName(context) + if (appName.isBlank()) return + if (FirebaseApp.getApps(context).any { it.name == appName }) { + logger.i(this, "Named FirebaseApp '$appName' already exists, reusing it") + return + } + runCatching { + FirebaseApp.initializeApp(context, FirebaseApp.getInstance().options, appName) + }.fold( + onSuccess = { logger.i(this, "Created isolated named FirebaseApp '$appName'") }, + onFailure = { error -> + logger.e(this, "Failed to create named FirebaseApp '$appName'", error) + }, + ) } override suspend fun getToken( context: Context ): String? = suspendCancellableCoroutine { continuation -> - FirebaseMessaging.getInstance().token + resolveFirebaseMessaging(context).token .addOnCanceledListener { continuation.resumeWithException(CancellationException()) } @@ -38,6 +66,29 @@ internal class FirebaseServiceHandler( .addOnFailureListener(continuation::resumeWithException) } + private fun resolveFirebaseMessaging(context: Context): FirebaseMessaging { + val appName = namedFirebaseAppName(context) + if (appName.isBlank()) { + return FirebaseMessaging.getInstance() + } + return runCatching { + FirebaseApp.getInstance(appName).get(FirebaseMessaging::class.java) + }.getOrElse { error -> + logger.e( + this, + "Could not resolve named FirebaseApp '$appName', " + + "falling back to [DEFAULT] FirebaseApp", + error, + ) + FirebaseMessaging.getInstance() + } + } + + private fun namedFirebaseAppName(context: Context): String = + cachedNamedAppName ?: exceptionHandler.runCatching(defaultValue = "") { + context.getString(R.string.mindbox_firebase_app_name).trim() + }.also { cachedNamedAppName = it } + override fun getAdsId(context: Context): Pair { val advertisingIdInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) val id = advertisingIdInfo.id diff --git a/mindbox-firebase/src/main/res/values/strings.xml b/mindbox-firebase/src/main/res/values/strings.xml new file mode 100644 index 00000000..f79e12f0 --- /dev/null +++ b/mindbox-firebase/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/mindbox-firebase/src/test/kotlin/cloud/mindbox/mindbox_firebase/FirebaseServiceHandlerTest.kt b/mindbox-firebase/src/test/kotlin/cloud/mindbox/mindbox_firebase/FirebaseServiceHandlerTest.kt new file mode 100644 index 00000000..bdb61e65 --- /dev/null +++ b/mindbox-firebase/src/test/kotlin/cloud/mindbox/mindbox_firebase/FirebaseServiceHandlerTest.kt @@ -0,0 +1,272 @@ +package cloud.mindbox.mindbox_firebase + +import android.content.Context +import cloud.mindbox.mobile_sdk.logger.MindboxLogger +import cloud.mindbox.mobile_sdk.utils.ExceptionHandler +import com.google.android.gms.tasks.OnCanceledListener +import com.google.android.gms.tasks.OnFailureListener +import com.google.android.gms.tasks.OnSuccessListener +import com.google.android.gms.tasks.Task +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.messaging.FirebaseMessaging +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test + +class FirebaseServiceHandlerTest { + + private val context = mockk() + private val logger = mockk(relaxed = true) + + // Real handler (not a mock): runCatching must execute the block so the + // resource lookup actually runs. + private val exceptionHandler = object : ExceptionHandler() { + override fun handle(exception: Throwable) = Unit + } + private val handler = FirebaseServiceHandler( + logger = logger, + exceptionHandler = exceptionHandler, + ) + + @After + fun tearDown() = unmockkAll() + + /** + * Stubs the optional `mindbox_firebase_app_name` resource. The empty string is the + * shipped default (host app didn't override it). + */ + private fun stubAppName(value: String) { + every { context.getString(R.string.mindbox_firebase_app_name) } returns value + } + + private fun stubDefaultMessaging(token: String) { + val defaultMessaging = mockk { + every { this@mockk.token } returns successTask(token) + } + mockkStatic(FirebaseMessaging::class) + every { FirebaseMessaging.getInstance() } returns defaultMessaging + } + + private fun successTask(token: String): Task { + val task = mockk>() + every { task.addOnCanceledListener(any()) } returns task + every { task.addOnSuccessListener(any>()) } answers { + firstArg>().onSuccess(token) + task + } + every { task.addOnFailureListener(any()) } returns task + return task + } + + // --- Token resolution (resolveFirebaseMessaging via registerToken) --- + + @Test + fun `resource blank (default) - token read from DEFAULT FirebaseApp (backward compatible)`() = runTest { + stubAppName("") + stubDefaultMessaging("default-token") + + val token = handler.registerToken(context, previousToken = null) + + assertEquals("default-token", token) + verify(exactly = 1) { FirebaseMessaging.getInstance() } + } + + @Test + fun `resource whitespace-only - treated as blank, uses DEFAULT FirebaseApp`() = runTest { + stubAppName(" ") + stubDefaultMessaging("default-token") + + val token = handler.registerToken(context, previousToken = null) + + assertEquals("default-token", token) + verify(exactly = 1) { FirebaseMessaging.getInstance() } + } + + @Test + fun `resource set and named app present - token read from named FirebaseApp`() = runTest { + stubAppName("mindbox-named") + val namedMessaging = mockk { + every { token } returns successTask("named-token") + } + val namedApp = mockk { + every { get(FirebaseMessaging::class.java) } returns namedMessaging + } + mockkStatic(FirebaseApp::class) + every { FirebaseApp.getInstance("mindbox-named") } returns namedApp + mockkStatic(FirebaseMessaging::class) + every { FirebaseMessaging.getInstance() } returns mockk() + + val token = handler.registerToken(context, previousToken = null) + + assertEquals("named-token", token) + verify(exactly = 1) { FirebaseApp.getInstance("mindbox-named") } + // Isolation: the named path must never touch the [DEFAULT] instance. + verify(exactly = 0) { FirebaseMessaging.getInstance() } + } + + @Test + fun `resource with surrounding whitespace - trimmed before use as named app`() = runTest { + stubAppName(" mindbox-named ") + val namedMessaging = mockk { + every { token } returns successTask("named-token") + } + val namedApp = mockk { + every { get(FirebaseMessaging::class.java) } returns namedMessaging + } + mockkStatic(FirebaseApp::class) + every { FirebaseApp.getInstance("mindbox-named") } returns namedApp + mockkStatic(FirebaseMessaging::class) + every { FirebaseMessaging.getInstance() } returns mockk() + + val token = handler.registerToken(context, previousToken = null) + + assertEquals("named-token", token) + // Trimmed name is used, not the raw " mindbox-named ". + verify(exactly = 1) { FirebaseApp.getInstance("mindbox-named") } + } + + @Test + fun `resource set but named app unresolvable - falls back to DEFAULT FirebaseApp`() = runTest { + stubAppName("does-not-exist") + mockkStatic(FirebaseApp::class) + every { FirebaseApp.getInstance("does-not-exist") } throws + IllegalStateException("FirebaseApp with name does-not-exist doesn't exist.") + stubDefaultMessaging("default-token") + + val token = handler.registerToken(context, previousToken = null) + + assertEquals("default-token", token) + verify(exactly = 1) { FirebaseMessaging.getInstance() } + // A clear error naming the missing app is logged before the fallback. + verify { logger.e(any(), match { it.contains("does-not-exist") }, any()) } + } + + // --- initService: SDK creates the isolated named app --- + + @Test + fun `initService creates named app from DEFAULT options when resource set and app absent`() = runTest { + stubAppName("mindbox-named") + val defaultOptions = mockk() + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(context) } returns mockk() + every { FirebaseApp.getApps(context) } returns emptyList() + every { FirebaseApp.getInstance() } returns mockk { every { options } returns defaultOptions } + every { FirebaseApp.initializeApp(context, defaultOptions, "mindbox-named") } returns mockk() + + handler.initService(context) + + verify(exactly = 1) { FirebaseApp.initializeApp(context, defaultOptions, "mindbox-named") } + // AC2: the configured app name is visible in the logs (logged once, at creation). + verify { logger.i(any(), match { it.contains("mindbox-named") }) } + } + + @Test + fun `initService does not recreate named app when client already created it`() = runTest { + stubAppName("mindbox-named") + val existing = mockk { every { name } returns "mindbox-named" } + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(context) } returns mockk() + every { FirebaseApp.getApps(context) } returns listOf(existing) + + handler.initService(context) + + verify(exactly = 0) { FirebaseApp.initializeApp(context, any(), any()) } + } + + @Test + fun `initService logs error and does not crash when named app creation fails`() = runTest { + stubAppName("mindbox-named") + val defaultOptions = mockk() + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(context) } returns mockk() + every { FirebaseApp.getApps(context) } returns emptyList() + every { FirebaseApp.getInstance() } returns mockk { every { options } returns defaultOptions } + every { FirebaseApp.initializeApp(context, defaultOptions, "mindbox-named") } throws + IllegalStateException("boom") + + handler.initService(context) // must not throw + + verify { logger.e(any(), match { it.contains("Failed to create") }, any()) } + } + + @Test + fun `initService does not create named app when resource is blank`() = runTest { + stubAppName("") + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(context) } returns mockk() + + handler.initService(context) + + verify(exactly = 0) { FirebaseApp.initializeApp(context, any(), any()) } + } + + // --- End-to-end: initService creates the app, getToken then reads from it --- + + @Test + fun `initService creates named app and a subsequent getToken reads its token`() = runTest { + stubAppName("mindbox-named") + val defaultOptions = mockk() + val namedMessaging = mockk { + every { token } returns successTask("named-token") + } + val namedApp = mockk { + every { get(FirebaseMessaging::class.java) } returns namedMessaging + } + mockkStatic(FirebaseApp::class) + every { FirebaseApp.initializeApp(context) } returns mockk() + every { FirebaseApp.getApps(context) } returns emptyList() + every { FirebaseApp.getInstance() } returns mockk { every { options } returns defaultOptions } + every { FirebaseApp.initializeApp(context, defaultOptions, "mindbox-named") } returns namedApp + every { FirebaseApp.getInstance("mindbox-named") } returns namedApp + mockkStatic(FirebaseMessaging::class) + every { FirebaseMessaging.getInstance() } returns mockk() + + handler.initService(context) + val token = handler.registerToken(context, previousToken = null) + + assertEquals("named-token", token) + verify(exactly = 1) { FirebaseApp.initializeApp(context, defaultOptions, "mindbox-named") } + verify(exactly = 0) { FirebaseMessaging.getInstance() } + // Resource read once across the whole init -> token sequence (memoization). + verify(exactly = 1) { context.getString(R.string.mindbox_firebase_app_name) } + } + + // --- Memoization: resource read once --- + + @Test + fun `resource is read only once across multiple token requests - default path`() = runTest { + stubAppName("") + stubDefaultMessaging("default-token") + + handler.registerToken(context, previousToken = null) + handler.registerToken(context, previousToken = null) + + verify(exactly = 1) { context.getString(R.string.mindbox_firebase_app_name) } + } + + @Test + fun `resource is read only once across multiple token requests - named path`() = runTest { + stubAppName("mindbox-named") + val namedApp = mockk { + every { get(FirebaseMessaging::class.java) } returns mockk { + every { token } returns successTask("named-token") + } + } + mockkStatic(FirebaseApp::class) + every { FirebaseApp.getInstance("mindbox-named") } returns namedApp + mockkStatic(FirebaseMessaging::class) + every { FirebaseMessaging.getInstance() } returns mockk() + + handler.registerToken(context, previousToken = null) + handler.registerToken(context, previousToken = null) + + verify(exactly = 1) { context.getString(R.string.mindbox_firebase_app_name) } + } +} 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 88cde261..7a6bbdf3 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -1314,7 +1314,7 @@ public object Mindbox : MindboxLog { mindboxLogI( "updateAppInfo. pushToken: $pushTokens, isNotificationEnabled: $isNotificationEnabled, " + - "old isNotificationEnabled: $savedPushTokens" + "old isNotificationEnabled: $savedIsNotificationEnabled" ) val initData = UpdateData( isNotificationsEnabled = isNotificationEnabled, From f9aa51fac033252bf57531d32703ba8250bc8c17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:28:34 +0300 Subject: [PATCH 25/37] deps(deps): bump actions/setup-java in the github-actions group --- .github/workflows/lint_unitTests_build.yml | 6 +++--- .github/workflows/publish-reusable.yml | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index 6454e806..87c08f7a 100644 --- a/.github/workflows/lint_unitTests_build.yml +++ b/.github/workflows/lint_unitTests_build.yml @@ -24,7 +24,7 @@ jobs: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: 'temurin' java-version: '17' @@ -75,7 +75,7 @@ jobs: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: 'temurin' java-version: '17' @@ -125,7 +125,7 @@ jobs: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/publish-reusable.yml b/.github/workflows/publish-reusable.yml index d3d618e0..8ae7fcdf 100644 --- a/.github/workflows/publish-reusable.yml +++ b/.github/workflows/publish-reusable.yml @@ -35,7 +35,7 @@ jobs: submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: 'temurin' java-version: '17' @@ -54,7 +54,7 @@ jobs: submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: 'temurin' java-version: '17' @@ -74,7 +74,7 @@ jobs: submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: 'temurin' java-version: '17' @@ -119,7 +119,7 @@ jobs: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: 'temurin' java-version: '17' From 282a05cf37cb2c8d1add0b5b86419234f0bdd67d Mon Sep 17 00:00:00 2001 From: Sergey Sozinov <103035673+sergeysozinov@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:45:11 +0300 Subject: [PATCH 26/37] MOBILE-213: send tags for inapps operations --- .../cloud/mindbox/mobile_sdk/Extensions.kt | 8 + .../mobile_sdk/di/modules/DomainModule.kt | 3 +- .../di/modules/PresentationModule.kt | 3 +- .../data/managers/FeatureToggleManagerImpl.kt | 1 + .../data/managers/InAppFailureTrackerImpl.kt | 20 +- .../managers/InAppSerializationManagerImpl.kt | 20 +- .../data/repositories/InAppRepositoryImpl.kt | 8 +- .../inapp/domain/InAppInteractorImpl.kt | 4 +- .../domain/InAppProcessingManagerImpl.kt | 34 ++- .../extensions/TrackingFailureExtension.kt | 16 +- .../interfaces/interactors/InAppInteractor.kt | 2 +- .../managers/InAppFailureTracker.kt | 6 +- .../managers/InAppSerializationManager.kt | 4 +- .../repositories/InAppRepository.kt | 4 +- .../inapp/domain/models/InAppTypeWrapper.kt | 1 + .../presentation/InAppMessageManagerImpl.kt | 11 +- .../presentation/InAppMessageViewDisplayer.kt | 1 + .../InAppMessageViewDisplayerImpl.kt | 12 +- .../view/AbstractInAppViewHolder.kt | 9 +- .../view/WebViewInappViewHolder.kt | 25 +- .../view/WebViewOperationExecutor.kt | 67 ++++- .../operation/request/InAppHandleRequest.kt | 17 -- ...owFailure.kt => InAppOperationRequests.kt} | 27 +- .../mindbox/mobile_sdk/ExtensionsKtTest.kt | 26 ++ .../managers/InAppFailureTrackerImplTest.kt | 42 +++ .../managers/InAppSerializationManagerTest.kt | 44 +++- .../data/repositories/InAppRepositoryTest.kt | 18 +- .../inapp/domain/InAppInteractorImplTest.kt | 7 +- .../domain/InAppProcessingManagerTest.kt | 223 ++++++++++++++-- .../presentation/InAppMessageManagerTest.kt | 121 ++++++++- .../InAppMessageViewDisplayerImplTest.kt | 46 +++- .../view/WebViewOperationExecutorTest.kt | 241 ++++++++++++++++-- .../request/InAppHandleRequestStub.kt | 7 - 33 files changed, 921 insertions(+), 157 deletions(-) delete mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt rename sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/{InAppShowFailure.kt => InAppOperationRequests.kt} (65%) delete mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequestStub.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt index a6ae6ddc..276bcfda 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt @@ -302,6 +302,14 @@ internal fun List.sortByPriority(): List { return this.sortedByDescending { it.isPriority } } +/** + * Tags to attach to operations emitted by this in-app: [InApp.tags] when non-empty and the tags + * feature is enabled, otherwise `null` (so Gson omits the `tags` key downstream). The feature-flag + * decision is taken by the caller and passed in as [isTagsFeatureEnabled]. + */ +internal fun InApp.gatedTags(isTagsFeatureEnabled: Boolean): Map? = + tags?.takeIf { it.isNotEmpty() && isTagsFeatureEnabled } + internal inline fun Queue.pollIf(predicate: (T) -> Boolean): T? { return peek()?.takeIf(predicate)?.let { poll() } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt index a2cf4ba4..36753134 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt @@ -47,7 +47,8 @@ internal fun DomainModule( inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = inAppContentFetcher, inAppRepository = inAppRepository, - inAppFailureTracker = inAppFailureTracker + inAppFailureTracker = inAppFailureTracker, + featureToggleManager = featureToggleManager ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt index de83c9b6..f05beac2 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt @@ -29,7 +29,8 @@ internal fun PresentationModule( sessionStorageManager = sessionStorageManager, userVisitManager = userVisitManager, inAppMessageDelayedManager = inAppMessageDelayedManager, - timeProvider = timeProvider + timeProvider = timeProvider, + featureToggleManager = featureToggleManager ) } override val clipboardManager: ClipboardManager by lazy { 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 3407125c..c580c6ad 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 @@ -5,6 +5,7 @@ import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse import java.util.concurrent.ConcurrentHashMap internal const val SEND_INAPP_SHOW_ERROR_FEATURE = "MobileSdkShouldSendInAppShowError" +internal const val SEND_INAPP_TAGS_FEATURE = "MobileSdkShouldSendInAppTags" internal class FeatureToggleManagerImpl : FeatureToggleManager { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt index 8b9fd6a3..580338fd 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt @@ -44,7 +44,12 @@ internal class InAppFailureTrackerImpl( inAppRepository.sendInAppShowFailure(listOf(failure)) } - override fun sendFailure(inAppId: String, failureReason: FailureReason, errorDetails: String?) { + override fun sendFailure( + inAppId: String, + failureReason: FailureReason, + errorDetails: String?, + tags: Map? + ) { val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) .convertToZonedDateTimeAtUTC() .convertToString() @@ -54,12 +59,18 @@ internal class InAppFailureTrackerImpl( inAppId = inAppId, failureReason = failureReason, errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), - dateTimeUtc = timestamp + dateTimeUtc = timestamp, + tags = tags ) ) } - override fun collectFailure(inAppId: String, failureReason: FailureReason, errorDetails: String?) { + override fun collectFailure( + inAppId: String, + failureReason: FailureReason, + errorDetails: String?, + tags: Map? + ) { val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) .convertToZonedDateTimeAtUTC() .convertToString() @@ -68,7 +79,8 @@ internal class InAppFailureTrackerImpl( inAppId = inAppId, failureReason = failureReason, errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), - dateTimeUtc = timestamp + dateTimeUtc = timestamp, + tags = tags ) ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt index d80cff2c..675e549e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt @@ -3,9 +3,10 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper -import cloud.mindbox.mobile_sdk.models.operation.request.InAppHandleRequest +import cloud.mindbox.mobile_sdk.models.operation.request.InAppClickRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.models.operation.request.InAppTargetingRequest import cloud.mindbox.mobile_sdk.toJsonTyped import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler import cloud.mindbox.mobile_sdk.utils.loggingRunCatching @@ -30,9 +31,20 @@ internal class InAppSerializationManagerImpl(private val gson: Gson) : InAppSeri } } - override fun serializeToInAppActionString(inAppId: String): String { - return loggingRunCatching("") { - gson.toJsonTyped(InAppHandleRequest(inAppId = inAppId)) + override fun serializeToInAppTargetingString(inAppId: String, tags: Map?): String { + return loggingRunCatching(defaultValue = "") { + gson.toJsonTyped(InAppTargetingRequest(inAppId = inAppId, tags = tags)) + } + } + + override fun serializeToInAppClickActionString(inAppId: String, tags: Map?): String { + return loggingRunCatching(defaultValue = "") { + gson.toJsonTyped( + InAppClickRequest( + inAppId = inAppId, + tags = tags, + ) + ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt index 1e0ad72a..19f31bf4 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt @@ -109,8 +109,8 @@ internal class InAppRepositoryImpl( } } - override fun sendInAppClicked(inAppId: String) { - inAppSerializationManager.serializeToInAppActionString(inAppId).apply { + override fun sendInAppClicked(inAppId: String, tags: Map?) { + inAppSerializationManager.serializeToInAppClickActionString(inAppId, tags).apply { if (isNotBlank()) { MindboxEventManager.inAppClicked( context, @@ -120,8 +120,8 @@ internal class InAppRepositoryImpl( } } - override fun sendUserTargeted(inAppId: String) { - inAppSerializationManager.serializeToInAppActionString(inAppId).apply { + override fun sendUserTargeted(inAppId: String, tags: Map?) { + inAppSerializationManager.serializeToInAppTargetingString(inAppId, tags).apply { if (isNotBlank()) { MindboxEventManager.sendUserTargeted( context, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt index 758e8613..0cb0ca77 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt @@ -118,8 +118,8 @@ internal class InAppInteractorImpl( inAppRepository.saveInAppStateChangeTime(timeStamp.toTimestamp()) } - override fun sendInAppClicked(inAppId: String) { - inAppRepository.sendInAppClicked(inAppId) + override fun sendInAppClicked(inAppId: String, tags: Map?) { + inAppRepository.sendInAppClicked(inAppId, tags) } override suspend fun listenToTargetingEvents() { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt index 271a93f0..6294f0d1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain import cloud.mindbox.mobile_sdk.Mindbox.logI +import cloud.mindbox.mobile_sdk.gatedTags import cloud.mindbox.mobile_sdk.getErrorResponseBodyData import cloud.mindbox.mobile_sdk.getImageUrl import cloud.mindbox.mobile_sdk.inapp.domain.extensions.asVolleyError @@ -8,7 +9,9 @@ import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getProductFromTargetingD import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getVolleyErrorDetails import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackImageDownloadError import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackTargetingError +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppProcessingManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository @@ -27,7 +30,8 @@ internal class InAppProcessingManagerImpl( private val inAppTargetingErrorRepository: InAppTargetingErrorRepository, private val inAppContentFetcher: InAppContentFetcher, private val inAppRepository: InAppRepository, - private val inAppFailureTracker: InAppFailureTracker + private val inAppFailureTracker: InAppFailureTracker, + private val featureToggleManager: FeatureToggleManager ) : InAppProcessingManager { companion object { @@ -35,12 +39,16 @@ internal class InAppProcessingManagerImpl( "CheckCustomerSegments requires customer" } + private fun isTagsFeatureEnabled(): Boolean = + featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) + override suspend fun chooseInAppToShow( inApps: List, triggerEvent: InAppEventType, ): InApp? { for (inApp in inApps) { val data = getTargetingData(triggerEvent) + val tags = inApp.gatedTags(isTagsFeatureEnabled()) var isTargetingErrorOccurred = false var isInAppContentFetched: Boolean? = null var targetingCheck = false @@ -105,7 +113,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.sendFailure( inAppId = inApp.id, failureReason = FailureReason.UNKNOWN_ERROR, - errorDetails = "Unknown exception when checking target ${throwable.message}. ${throwable.cause?.getVolleyErrorDetails() ?: "volleyError=null"}" + errorDetails = "Unknown exception when checking target ${throwable.message}. ${throwable.cause?.getVolleyErrorDetails() ?: "volleyError=null"}", + tags = tags ) throw throwable } @@ -125,13 +134,14 @@ internal class InAppProcessingManagerImpl( } mindboxLogD("loading and targeting fetching finished") if (isTargetingErrorOccurred) return chooseInAppToShow(inApps, triggerEvent) - trackTargetingErrorIfAny(inApp, data) + trackTargetingErrorIfAny(inApp, data, tags) if (isInAppContentFetched == false && targetingCheck) { imageFetchError?.takeIf { it.shouldTrackImageDownloadError() }?.let { error -> inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, - errorDetails = (error.message ?: "Image loading error") + "\n Url is ${inApp.form.variants.first().getImageUrl()}" + errorDetails = (error.message ?: "Image loading error") + "\n Url is ${inApp.form.variants.first().getImageUrl()}", + tags = tags ) } } @@ -184,7 +194,10 @@ internal class InAppProcessingManagerImpl( if (isTargetingErrorOccurred) return sendTargetedInApp(inApp, triggerEvent) if (inApp.targeting.checkTargeting(data)) { logI("InApp with id = ${inApp.id} sends targeting by event $triggerEvent") - inAppRepository.sendUserTargeted(inAppId = inApp.id) + inAppRepository.sendUserTargeted( + inAppId = inApp.id, + tags = inApp.gatedTags(isTagsFeatureEnabled()), + ) } } @@ -210,7 +223,7 @@ internal class InAppProcessingManagerImpl( mindboxLogW("Error fetching customer segmentations", error) } - private fun trackTargetingErrorIfAny(inApp: InApp, data: TargetingData) { + private fun trackTargetingErrorIfAny(inApp: InApp, data: TargetingData, tags: Map?) { when { inApp.targeting.hasSegmentationNode() && inAppSegmentationRepository.getCustomerSegmentationFetched() == CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR -> { @@ -219,7 +232,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } return @@ -232,7 +246,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.GEO_TARGETING_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } return @@ -246,7 +261,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.PRODUCT_SEGMENT_REQUEST_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt index 2f15015d..e2f5453e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt @@ -82,7 +82,8 @@ private fun parseOperationBody(operationBody: String?): Pair? = internal fun InAppFailureTracker.sendPresentationFailure( inAppId: String, errorDescription: String, - throwable: Throwable? = null + throwable: Throwable? = null, + tags: Map? = null ) { val errorDetails = when { throwable != null -> "$errorDescription: ${throwable.message ?: "Unknown error"}" @@ -92,7 +93,8 @@ internal fun InAppFailureTracker.sendPresentationFailure( sendFailure( inAppId = inAppId, failureReason = FailureReason.PRESENTATION_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } @@ -100,7 +102,8 @@ internal fun InAppFailureTracker.sendFailureWithContext( inAppId: String, failureReason: FailureReason, errorDescription: String, - throwable: Throwable? = null + throwable: Throwable? = null, + tags: Map? = null ) { val errorDetails = when { throwable != null -> "$errorDescription: ${throwable.message ?: "Unknown error"}" @@ -110,7 +113,8 @@ internal fun InAppFailureTracker.sendFailureWithContext( sendFailure( inAppId = inAppId, failureReason = failureReason, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } @@ -118,6 +122,7 @@ internal inline fun InAppFailureTracker.executeWithFailureTracking( inAppId: String, failureReason: FailureReason, errorDescription: String, + tags: Map? = null, crossinline onFailure: () -> Unit = {}, block: () -> T ): Result { @@ -126,7 +131,8 @@ internal inline fun InAppFailureTracker.executeWithFailureTracking( inAppId = inAppId, failureReason = failureReason, errorDescription = errorDescription, - throwable = throwable + throwable = throwable, + tags = tags ) onFailure() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt index 44d940eb..c755790a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt @@ -19,7 +19,7 @@ internal interface InAppInteractor { tags: Map? ) - fun sendInAppClicked(inAppId: String) + fun sendInAppClicked(inAppId: String, tags: Map?) suspend fun fetchMobileConfig() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt index 9c4f4e26..7d53d8da 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt @@ -7,13 +7,15 @@ internal interface InAppFailureTracker { fun sendFailure( inAppId: String, failureReason: FailureReason, - errorDetails: String? + errorDetails: String?, + tags: Map? = null ) fun collectFailure( inAppId: String, failureReason: FailureReason, - errorDetails: String? + errorDetails: String?, + tags: Map? = null ) fun sendCollectedFailures() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt index e01acce0..d10e48bc 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt @@ -10,7 +10,9 @@ internal interface InAppSerializationManager { fun serializeToInAppShownActionString(inAppId: String, timeToDisplay: String, tags: Map?): String - fun serializeToInAppActionString(inAppId: String): String + fun serializeToInAppTargetingString(inAppId: String, tags: Map?): String + + fun serializeToInAppClickActionString(inAppId: String, tags: Map?): String fun serializeToInAppShowFailuresString(inAppShowFailures: List): String diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt index eba668ef..b40c0101 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt @@ -31,9 +31,9 @@ internal interface InAppRepository { fun sendInAppShown(inAppId: String, timeToDisplay: String, tags: Map?) - fun sendInAppClicked(inAppId: String) + fun sendInAppClicked(inAppId: String, tags: Map?) - fun sendUserTargeted(inAppId: String) + fun sendUserTargeted(inAppId: String, tags: Map?) fun sendInAppShowFailure(failures: List) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt index f520c7ab..8ad560b2 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt @@ -6,6 +6,7 @@ internal data class InAppTypeWrapper( val inAppType: T, val inAppActionCallbacks: InAppActionCallbacks, val onRenderStart: () -> Unit, + val tags: Map? = null, ) internal fun interface OnInAppClick { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt index f8ee03ee..9d569887 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt @@ -3,8 +3,11 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.app.Activity import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.gatedTags +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppClick @@ -34,7 +37,8 @@ internal class InAppMessageManagerImpl( private val sessionStorageManager: SessionStorageManager, private val userVisitManager: UserVisitManager, private val inAppMessageDelayedManager: InAppMessageDelayedManager, - private val timeProvider: TimeProvider + private val timeProvider: TimeProvider, + private val featureToggleManager: FeatureToggleManager, ) : InAppMessageManager { init { @@ -93,14 +97,15 @@ internal class InAppMessageManagerImpl( } var renderStartTime = Timestamp(0L) - val tags = inApp.tags?.takeIf { it.isNotEmpty() } + val tags = inApp.gatedTags(featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE)) inAppMessageViewDisplayer.tryShowInAppMessage( inAppType = inAppMessage, onRenderStart = { renderStartTime = timeProvider.currentTimestamp() }, + tags = tags, inAppActionCallbacks = object : InAppActionCallbacks { override val onInAppClick = OnInAppClick { - inAppInteractor.sendInAppClicked(inAppMessage.inAppId) + inAppInteractor.sendInAppClicked(inAppMessage.inAppId, tags) } override val onInAppShown = OnInAppShown { handleInAppShown(renderStartTime, preparedTimeMs, inAppMessage, tags) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt index 6caf48ff..52b089e5 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt @@ -16,6 +16,7 @@ internal interface InAppMessageViewDisplayer { inAppType: InAppType, inAppActionCallbacks: InAppActionCallbacks, onRenderStart: () -> Unit = {}, + tags: Map? = null, ) fun registerCurrentActivity(activity: Activity) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 3a978bea..5d61e57e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -137,8 +137,9 @@ internal class InAppMessageViewDisplayerImpl( inAppType: InAppType, inAppActionCallbacks: InAppActionCallbacks, onRenderStart: () -> Unit, + tags: Map?, ) { - val wrapper = InAppTypeWrapper(inAppType, inAppActionCallbacks, onRenderStart) + val wrapper = InAppTypeWrapper(inAppType, inAppActionCallbacks, onRenderStart, tags) if (isUiPresent() && currentHolder == null && pausedHolder == null) { val duration = Stopwatch.track(Stopwatch.INIT_SDK) @@ -208,6 +209,7 @@ internal class InAppMessageViewDisplayerImpl( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying draw inapp", + tags = wrapper.tags, onFailure = ::closeInApp ) { currentHolder?.show(createMindboxView(root)) @@ -215,7 +217,8 @@ internal class InAppMessageViewDisplayerImpl( } ?: run { inAppFailureTracker.sendPresentationFailure( inAppId = wrapper.inAppType.inAppId, - errorDescription = "currentRoot is null" + errorDescription = "currentRoot is null", + tags = wrapper.tags ) } } @@ -226,10 +229,12 @@ internal class InAppMessageViewDisplayerImpl( ?: return false currentHolder = restoredHolder pausedHolder = null + val restoredTags = restoredHolder.wrapper.tags val root: ViewGroup = currentActivity?.root ?: run { inAppFailureTracker.sendPresentationFailure( inAppId = inAppId, - errorDescription = "failed to reattach inApp: currentRoot is null" + errorDescription = "failed to reattach inApp: currentRoot is null", + tags = restoredTags ) return true } @@ -237,6 +242,7 @@ internal class InAppMessageViewDisplayerImpl( inAppId = inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying reattach InApp", + tags = restoredTags, onFailure = ::closeInApp, ) { restoredHolder.reattach(createMindboxView(root)) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index fa5a37b8..f4565da4 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -152,14 +152,16 @@ internal abstract class AbstractInAppViewHolder( inAppFailureTracker.sendPresentationFailure( inAppId = wrapper.inAppType.inAppId, errorDescription = "Failed to load in-app image with url = $url", - throwable = e + throwable = e, + tags = wrapper.tags ) inAppController.close() }.onFailure { throwable -> inAppFailureTracker.sendPresentationFailure( inAppId = wrapper.inAppType.inAppId, errorDescription = "Unknown error in onLoadFailed callback for url = $url", - throwable = throwable + throwable = throwable, + tags = wrapper.tags ) } return false @@ -184,7 +186,8 @@ internal abstract class AbstractInAppViewHolder( inAppFailureTracker.sendPresentationFailure( inAppId = wrapper.inAppType.inAppId, errorDescription = "Unknown error in onResourceReady callback for url = $url", - throwable = throwable + throwable = throwable, + tags = wrapper.tags ) } return false 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 9542b7c9..31cdb714 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 @@ -97,7 +97,7 @@ internal class WebViewInAppViewHolder( private val mindboxNotificationManager: MindboxNotificationManager by mindboxInject { mindboxNotificationManager } private val appContext: Application by mindboxInject { appContext } private val operationExecutor: WebViewOperationExecutor by lazy { - MindboxWebViewOperationExecutor() + MindboxWebViewOperationExecutor(gson) } private val linkRouter: WebViewLinkRouter by lazy { MindboxWebViewLinkRouter(appContext) @@ -351,7 +351,7 @@ internal class WebViewInAppViewHolder( } private fun handleAsyncOperationAction(message: BridgeMessage.Request): String { - operationExecutor.executeAsyncOperation(appContext, message.payload) + operationExecutor.executeAsyncOperation(appContext, message.payload, wrapper.tags) return BridgeMessage.EMPTY_PAYLOAD } @@ -364,7 +364,7 @@ internal class WebViewInAppViewHolder( } private suspend fun handleSyncOperationAction(message: BridgeMessage.Request): String { - return operationExecutor.executeSyncOperation(message.payload) + return operationExecutor.executeSyncOperation(message.payload, wrapper.tags) } private fun handleLocalStateGetAction(message: BridgeMessage.Request): String { @@ -439,7 +439,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}" + errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}", + tags = wrapper.tags ) inAppController.close() } @@ -521,7 +522,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "evaluateJavaScript return unexpected response: $response" + errorDescription = "evaluateJavaScript return unexpected response: $response", + tags = wrapper.tags ) inAppController.close() false @@ -646,7 +648,8 @@ internal class WebViewInAppViewHolder( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = "Failed to fetch HTML content for In-App", - throwable = e + throwable = e, + tags = wrapper.tags ) controller.executeOnViewThread { inAppController.close() } } @@ -654,7 +657,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_LOAD_FAILED, - errorDescription = "WebView content URL is null" + errorDescription = "WebView content URL is null", + tags = wrapper.tags ) controller.executeOnViewThread { inAppController.close() } } @@ -666,6 +670,7 @@ internal class WebViewInAppViewHolder( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying WebView layout", + tags = wrapper.tags, ) { val view: WebViewPlatformView = controller.view if (view.parent !== inAppLayout) { @@ -677,7 +682,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "WebView controller is null when trying show inapp" + errorDescription = "WebView controller is null when trying show inapp", + tags = wrapper.tags ) inAppController.close() } @@ -692,7 +698,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_LOAD_FAILED, - errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." + errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}.", + tags = wrapper.tags ) controller.executeOnViewThread { inAppController.close() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt index 80812e9f..a7f9f54f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt @@ -1,8 +1,11 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Application +import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.MindboxError +import com.google.gson.Gson +import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser import kotlinx.coroutines.suspendCancellableCoroutine @@ -11,20 +14,23 @@ import kotlin.coroutines.resumeWithException internal interface WebViewOperationExecutor { - fun executeAsyncOperation(context: Application, payload: String?) + fun executeAsyncOperation(context: Application, payload: String?, tags: Map?) - suspend fun executeSyncOperation(payload: String?): String + suspend fun executeSyncOperation(payload: String?, tags: Map?): String } -internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { +internal class MindboxWebViewOperationExecutor( + private val gson: Gson, +) : WebViewOperationExecutor { companion object { private const val OPERATION_FIELD = "operation" private const val BODY_FIELD = "body" + private const val TAGS_FIELD = "tags" } - override fun executeAsyncOperation(context: Application, payload: String?) { - val (operation, body) = parseOperationRequest(payload) + override fun executeAsyncOperation(context: Application, payload: String?, tags: Map?) { + val (operation, body) = parseOperationRequest(payload, tags) MindboxEventManager.asyncOperation( context = context, name = operation, @@ -32,8 +38,8 @@ internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { ) } - override suspend fun executeSyncOperation(payload: String?): String { - val (operation, body) = parseOperationRequest(payload) + override suspend fun executeSyncOperation(payload: String?, tags: Map?): String { + val (operation, body) = parseOperationRequest(payload, tags) return suspendCancellableCoroutine { continuation -> MindboxEventManager.syncOperation( name = operation, @@ -54,12 +60,51 @@ internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { } } - private fun parseOperationRequest(payload: String?): Pair { - val jsonObject: JsonObject = JsonParser.parseString(payload).asJsonObject + private fun parseOperationRequest(payload: String?, tags: Map?): Pair { + payload ?: throw IllegalArgumentException("Payload is not provided") + val jsonObject: JsonObject = runCatching { JsonParser.parseString(payload).asJsonObject } + .getOrElse { throw IllegalArgumentException("Payload is not a valid JSON object", it) } val operation: String = jsonObject.getAsJsonPrimitive(OPERATION_FIELD)?.asString ?: throw IllegalArgumentException("Operation is not provided") - val body: String = jsonObject.getAsJsonObject(BODY_FIELD)?.toString() + val bodyObject: JsonObject = jsonObject.getAsJsonObject(BODY_FIELD) ?: throw IllegalArgumentException("Body is not provided") - return operation to body + return operation to buildOperationBody(bodyObject, tags) + } + + private fun buildOperationBody(bodyObject: JsonObject, tags: Map?): String { + if (!tags.isNullOrEmpty()) { + mergeTags(bodyObject, tags) + } + return bodyObject.toString() + } + + private fun mergeTags(bodyObject: JsonObject, tags: Map) { + val existingTags: JsonElement? = bodyObject.get(TAGS_FIELD) + when { + existingTags == null || existingTags.isJsonNull -> { + bodyObject.add(TAGS_FIELD, gson.toJsonTree(tags)) + } + + existingTags.isJsonObject -> { + val tagsObject: JsonObject = existingTags.asJsonObject + tags.forEach { (key: String, value: String) -> + if (tagsObject.has(key)) { + mindboxLogW( + "WebView operation body `tags` already contains key `$key`; " + + "keeping client value, skipping in-app value" + ) + } else { + tagsObject.addProperty(key, value) + } + } + } + + else -> { + mindboxLogW( + "WebView operation body `tags` is not a JSON object; " + + "keeping client value, skipping in-app tags" + ) + } + } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt deleted file mode 100644 index b8256c07..00000000 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package cloud.mindbox.mobile_sdk.models.operation.request - -import com.google.gson.annotations.SerializedName - -internal data class InAppHandleRequest( - @SerializedName("inappId") - val inAppId: String -) - -internal data class InAppShowRequest( - @SerializedName("inappId") - val inAppId: String, - @SerializedName("timeToDisplay") - val timeToDisplay: String, - @SerializedName("tags") - val tags: Map? -) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt similarity index 65% rename from sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt rename to sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt index c3a0b4fe..b1ea0ea8 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt @@ -2,6 +2,29 @@ package cloud.mindbox.mobile_sdk.models.operation.request import com.google.gson.annotations.SerializedName +internal data class InAppShowRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("timeToDisplay") + val timeToDisplay: String, + @SerializedName("tags") + val tags: Map? +) + +internal data class InAppClickRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("tags") + val tags: Map? +) + +internal data class InAppTargetingRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("tags") + val tags: Map? = null +) + internal data class InAppShowFailure( @SerializedName("inappId") val inAppId: String, @@ -10,7 +33,9 @@ internal data class InAppShowFailure( @SerializedName("errorDetails") val errorDetails: String?, @SerializedName("dateTimeUtc") - val dateTimeUtc: String + val dateTimeUtc: String, + @SerializedName("tags") + val tags: Map? = null ) internal enum class FailureReason(val value: String) { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt index ddded4e9..63432761 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk +import cloud.mindbox.mobile_sdk.models.InAppStub import org.junit.Assert.* import org.junit.Test @@ -45,4 +46,29 @@ class ExtensionsKtTest { assertFalse((null as String?).equalsAny("")) assertFalse((null as String?).equalsAny("null")) } + + @Test + fun `gatedTags returns tags when present and feature enabled`() { + val tags = mapOf("templateType" to "Popup") + val inApp = InAppStub.getInApp().copy(tags = tags) + assertEquals(tags, inApp.gatedTags(isTagsFeatureEnabled = true)) + } + + @Test + fun `gatedTags returns null when feature disabled`() { + val inApp = InAppStub.getInApp().copy(tags = mapOf("templateType" to "Popup")) + assertNull(inApp.gatedTags(isTagsFeatureEnabled = false)) + } + + @Test + fun `gatedTags returns null when tags empty`() { + val inApp = InAppStub.getInApp().copy(tags = emptyMap()) + assertNull(inApp.gatedTags(isTagsFeatureEnabled = true)) + } + + @Test + fun `gatedTags returns null when tags null`() { + val inApp = InAppStub.getInApp().copy(tags = null) + assertNull(inApp.gatedTags(isTagsFeatureEnabled = true)) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt index 3638f2e1..6a062ed2 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt @@ -204,6 +204,48 @@ internal class InAppFailureTrackerImplTest { verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } } + @Test + fun `sendFailure forwards tags to the failure`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val tags = mapOf("templateType" to "Popup") + val slot = slot>() + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error", + tags = tags + ) + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + assertEquals(tags, slot.captured[0].tags) + } + + @Test + fun `collectFailure keeps each failures own tags`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.collectFailure( + inAppId = "inApp1", + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null, + tags = mapOf("templateType" to "Popup") + ) + inAppFailureTracker.collectFailure( + inAppId = "inApp2", + failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, + errorDetails = null, + tags = null + ) + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + val captured = slot.captured + assertEquals(mapOf("templateType" to "Popup"), captured.first { it.inAppId == "inApp1" }.tags) + assertEquals(null, captured.first { it.inAppId == "inApp2" }.tags) + } + @Test fun `sendFailure with null errorDetails`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt index 71e30006..f690d7f9 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppClickRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import com.google.gson.Gson @@ -71,18 +72,53 @@ internal class InAppSerializationManagerTest { } @Test - fun `serializeToInAppActionString returns JSON with inAppId only`() { + fun `serializeToInAppTargetingString returns JSON with inAppId only when tags null`() { val expectedResult = "{\"inappId\":\"${inAppId}\"}" - val actualResult = inAppSerializationManager.serializeToInAppActionString(inAppId) + val actualResult = inAppSerializationManager.serializeToInAppTargetingString(inAppId, tags = null) assertEquals(expectedResult, actualResult) } @Test - fun `serializeToInAppActionString returns empty string on error`() { + fun `serializeToInAppTargetingString returns JSON with inAppId and tags when tags present`() { + val expectedResult = "{\"inappId\":\"${inAppId}\",\"tags\":{\"templateType\":\"Popup\"}}" + val actualResult = inAppSerializationManager.serializeToInAppTargetingString( + inAppId, + tags = mapOf("templateType" to "Popup"), + ) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `serializeToInAppTargetingString returns empty string on error`() { + val gson: Gson = mockk() + every { gson.toJson(any(), any>()) } throws Error("errorMessage") + inAppSerializationManager = InAppSerializationManagerImpl(gson) + val actualResult = inAppSerializationManager.serializeToInAppTargetingString(inAppId, tags = null) + assertEquals("", actualResult) + } + + @Test + fun `serializeToInAppClickActionString returns JSON with inAppId and tags`() { + val tags = mapOf("templateType" to "Popup") + val actualResult = inAppSerializationManager.serializeToInAppClickActionString(inAppId, tags) + val parsed = gson.fromJson(actualResult, InAppClickRequest::class.java) + assertEquals(inAppId, parsed.inAppId) + assertEquals(tags, parsed.tags) + } + + @Test + fun `serializeToInAppClickActionString omits tags when null`() { + val expectedResult = "{\"inappId\":\"${inAppId}\"}" + val actualResult = inAppSerializationManager.serializeToInAppClickActionString(inAppId, null) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `serializeToInAppClickActionString returns empty string on error`() { val gson: Gson = mockk() every { gson.toJson(any(), any>()) } throws Error("errorMessage") inAppSerializationManager = InAppSerializationManagerImpl(gson) - val actualResult = inAppSerializationManager.serializeToInAppActionString(inAppId) + val actualResult = inAppSerializationManager.serializeToInAppClickActionString(inAppId, null) assertEquals("", actualResult) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt index df681930..25569b02 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt @@ -147,9 +147,10 @@ class InAppRepositoryTest { @Test fun `send in app clicked success`() { val testInAppId = "testInAppId" + val tags = mapOf("templateType" to "Popup") val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendInAppClicked(testInAppId) + every { inAppSerializationManager.serializeToInAppClickActionString(testInAppId, tags) } returns serializedString + inAppRepository.sendInAppClicked(testInAppId, tags) verify(exactly = 1) { MindboxEventManager.inAppClicked(context, serializedString) } @@ -159,8 +160,8 @@ class InAppRepositoryTest { fun `send in app clicked empty string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendInAppClicked(testInAppId) + every { inAppSerializationManager.serializeToInAppClickActionString(any(), any()) } returns serializedString + inAppRepository.sendInAppClicked(testInAppId, null) verify(exactly = 0) { MindboxEventManager.inAppClicked(context, serializedString) } @@ -169,9 +170,10 @@ class InAppRepositoryTest { @Test fun `send user targeted success`() { val testInAppId = "testInAppId" + val tags = mapOf("templateType" to "Popup") val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendUserTargeted(testInAppId) + every { inAppSerializationManager.serializeToInAppTargetingString(testInAppId, tags) } returns serializedString + inAppRepository.sendUserTargeted(testInAppId, tags) verify(exactly = 1) { MindboxEventManager.sendUserTargeted(context, serializedString) } @@ -181,8 +183,8 @@ class InAppRepositoryTest { fun `send user targeted string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendUserTargeted(testInAppId) + every { inAppSerializationManager.serializeToInAppTargetingString(any(), any()) } returns serializedString + inAppRepository.sendUserTargeted(testInAppId, null) verify(exactly = 0) { MindboxEventManager.sendUserTargeted(context, serializedString) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt index 6f7dd52d..a78a231a 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt @@ -7,6 +7,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppEventManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFilteringManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFrequencyManager @@ -91,6 +92,9 @@ class InAppInteractorImplTest { @RelaxedMockK private lateinit var inAppFailureTracker: InAppFailureTracker + @RelaxedMockK + private lateinit var featureToggleManager: FeatureToggleManager + @Before fun setup() { interactor = InAppInteractorImpl( @@ -178,7 +182,8 @@ class InAppInteractorImplTest { inAppTargetingErrorRepository, inAppContentFetcher, inAppRepository, - inAppFailureTracker + inAppFailureTracker, + featureToggleManager ) interactor = InAppInteractorImpl( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index 03407e35..61e00249 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -2,12 +2,14 @@ package cloud.mindbox.mobile_sdk.inapp.domain import android.content.Context import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppGeoRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppSegmentationRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppTargetingErrorRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository @@ -44,7 +46,7 @@ internal class InAppProcessingManagerTest { private val mockInAppRepository = mockk { every { - sendUserTargeted(any()) + sendUserTargeted(any(), any()) } just runs every { saveTargetedInAppWithEvent(any(), any()) @@ -85,6 +87,7 @@ internal class InAppProcessingManagerTest { private val geoSerializationManager: GeoSerializationManager = mockk(relaxed = true) private val gatewayManager: GatewayManager = mockk(relaxed = true) private val inAppFailureTracker: InAppFailureTracker = mockk(relaxed = true) + private val featureToggleManager: FeatureToggleManager = mockk(relaxed = true) private val inAppTargetingErrorRepository: InAppTargetingErrorRepository = spyk(InAppTargetingErrorRepositoryImpl(sessionStorageManager)) @@ -137,7 +140,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = inAppFailureTracker + inAppFailureTracker = inAppFailureTracker, + featureToggleManager = featureToggleManager ) private val inAppProcessingManagerTestImpl = InAppProcessingManagerImpl( @@ -146,7 +150,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = inAppFailureTracker + inAppFailureTracker = inAppFailureTracker, + featureToggleManager = featureToggleManager ) private fun setupTestGeoRepositoryForErrorScenario() { @@ -177,7 +182,7 @@ internal class InAppProcessingManagerTest { InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") ) verify(exactly = 0) { - mockInAppRepository.sendUserTargeted(any()) + mockInAppRepository.sendUserTargeted(any(), any()) } } @@ -206,7 +211,42 @@ internal class InAppProcessingManagerTest { InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") ) verify(exactly = 2) { - mockInAppRepository.sendUserTargeted(any()) + mockInAppRepository.sendUserTargeted(any(), any()) + } + } + + @Test + fun `sendTargetedInApp passes inApp tags when tags feature enabled`() = runTest { + val tags = mapOf("templateType" to "Popup") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns true + val testInApp = mockk(relaxed = true) + every { testInApp.id } returns "123" + every { testInApp.tags } returns tags + every { testInApp.targeting.checkTargeting(any()) } returns true + coEvery { testInApp.targeting.fetchTargetingInfo(any()) } just runs + inAppProcessingManager.sendTargetedInApp( + testInApp, + InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") + ) + verify(exactly = 1) { + mockInAppRepository.sendUserTargeted(inAppId = "123", tags = tags) + } + } + + @Test + fun `sendTargetedInApp passes null tags when tags feature disabled`() = runTest { + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns false + val testInApp = mockk(relaxed = true) + every { testInApp.id } returns "123" + every { testInApp.tags } returns mapOf("templateType" to "Popup") + every { testInApp.targeting.checkTargeting(any()) } returns true + coEvery { testInApp.targeting.fetchTargetingInfo(any()) } just runs + inAppProcessingManager.sendTargetedInApp( + testInApp, + InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") + ) + verify(exactly = 1) { + mockInAppRepository.sendUserTargeted(inAppId = "123", tags = null) } } @@ -431,7 +471,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = mockk(relaxed = true), inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = mockk(relaxed = true) + inAppFailureTracker = mockk(relaxed = true), + featureToggleManager = featureToggleManager ) val expectedResult = InAppStub.getInApp().copy( @@ -467,7 +508,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionStorageManager.geoFetchStatus) } @@ -485,7 +526,7 @@ internal class InAppProcessingManagerTest { ) ) inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.customerSegmentationFetchStatus) } @@ -505,7 +546,7 @@ internal class InAppProcessingManagerTest { ) ) inAppProcessingManager.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -517,7 +558,7 @@ internal class InAppProcessingManagerTest { targeting = InAppStub.getTargetingCountryNode().copy(kind = Kind.NEGATIVE) ) inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionStorageManager.geoFetchStatus) } @@ -529,7 +570,7 @@ internal class InAppProcessingManagerTest { targeting = InAppStub.getTargetingSegmentNode().copy(kind = Kind.NEGATIVE) ) inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.customerSegmentationFetchStatus) } @@ -543,7 +584,7 @@ internal class InAppProcessingManagerTest { } ) inAppProcessingManager.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -561,7 +602,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -584,7 +625,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -604,7 +645,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -653,7 +694,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -735,7 +777,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val result = processingManager.chooseInAppToShow(testInAppList, event) @@ -780,7 +823,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -817,6 +861,140 @@ internal class InAppProcessingManagerTest { } } + @Test + fun `collected failure carries inApp tags when tags feature enabled`() = runTest { + val errorDetails = "Customer segmentation fetch failed. statusCode=500" + val tags = mapOf("templateType" to "Popup") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns true + val segmentationRepo = mockk { + coEvery { fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError()) + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + every { setCustomerSegmentationStatus(any()) } just runs + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + every { getProductSegmentationFetched(any()) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.CustomerSegmentation) } returns errorDetails + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, segmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = segmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + tags = tags, + targeting = InAppStub.getTargetingSegmentNode().copy( + type = "", + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + processingManager.chooseInAppToShow(testInAppList, event) + + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails, + tags = tags + ) + } + } + + @Test + fun `collected failure carries null tags when tags feature disabled`() = runTest { + val errorDetails = "Customer segmentation fetch failed. statusCode=500" + val tags = mapOf("templateType" to "Popup") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns false + val segmentationRepo = mockk { + coEvery { fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError()) + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + every { setCustomerSegmentationStatus(any()) } just runs + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + every { getProductSegmentationFetched(any()) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.CustomerSegmentation) } returns errorDetails + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, segmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = segmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + tags = tags, + targeting = InAppStub.getTargetingSegmentNode().copy( + type = "", + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + processingManager.chooseInAppToShow(testInAppList, event) + + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails, + tags = null + ) + } + } + @Test fun `trackTargetingErrorIfAny does not collect customer segmentation failure when error was not saved`() = runTest { val segmentationRepo = mockk { @@ -843,7 +1021,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -903,7 +1082,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -980,7 +1160,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt index c0e33f37..7ef0c801 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt @@ -1,8 +1,11 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.util.Log +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.managers.UserVisitManager @@ -59,6 +62,8 @@ internal class InAppMessageManagerTest { private val timeProvider = mockk() + private val featureToggleManager = mockk() + /** * sets a thread to be used as main dispatcher for running on JVM * **/ @@ -71,6 +76,7 @@ internal class InAppMessageManagerTest { coEvery { inAppMessageInteractor.listenToTargetingEvents() } just runs + every { featureToggleManager.isEnabled(any()) } returns true every { Log.isLoggable(any(), any()) }.answers { @@ -95,7 +101,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.fetchMobileConfig() @@ -118,7 +125,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) mockkObject(LoggingExceptionHandler) every { MindboxPreferences.inAppConfig } returns "test" @@ -157,7 +165,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.processEventAndConfig() @@ -171,7 +180,7 @@ internal class InAppMessageManagerTest { advanceUntilIdle() verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } - verify(exactly = 1) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } + verify(exactly = 1) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any(), any()) } } @Test @@ -188,7 +197,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.listenToTargetingEvents() @@ -211,7 +221,7 @@ internal class InAppMessageManagerTest { advanceUntilIdle() verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } coVerify(exactly = 1) { inAppMessageInteractor.listenToTargetingEvents() } - verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } + verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any(), any()) } } @Test @@ -228,7 +238,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.listenToTargetingEvents() @@ -251,7 +262,7 @@ internal class InAppMessageManagerTest { advanceUntilIdle() verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } coVerify(exactly = 1) { inAppMessageInteractor.listenToTargetingEvents() } - verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } + verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any(), any()) } } @Test @@ -264,7 +275,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.processEventAndConfig() @@ -299,7 +311,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) mockkConstructor(NetworkResponse::class) val networkResponse = mockk() @@ -334,7 +347,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) mockkConstructor(NetworkResponse::class) val networkResponse = mockk() @@ -368,4 +382,89 @@ internal class InAppMessageManagerTest { assertEquals(expectedInappList, resultInappList) } + + @Test + fun `tags feature on - tags passed to show and click`() = runTest { + val tags = mapOf("templateType" to "Popup") + val inAppToShowFlow = MutableSharedFlow>() + val inApp = InAppStub.getInApp().copy(tags = tags) + val inAppMessage = inApp.form.variants.first() + var capturedCallbacks: InAppActionCallbacks? = null + var capturedTags: Map? = null + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns true + every { inAppMessageViewDisplayer.isInAppActive() } returns false + every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns true + every { inAppMessageInteractor.sendInAppClicked(any(), any()) } just runs + every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow + every { inAppMessageDelayedManager.process(inApp, any()) } coAnswers { + this@runTest.launch { inAppToShowFlow.emit(inApp to Milliseconds(0L)) } + } + every { inAppMessageViewDisplayer.tryShowInAppMessage(any(), any(), any(), any()) } answers { + capturedCallbacks = arg(1) + capturedTags = arg(3) + } + inAppMessageManager = InAppMessageManagerImpl( + inAppMessageViewDisplayer, + inAppMessageInteractor, + testDispatcher, + monitoringRepository, + sessionStorageManager, + userVisitManager, + inAppMessageDelayedManager, + timeProvider, + featureToggleManager + ) + coEvery { inAppMessageInteractor.processEventAndConfig() } answers { + flow { emit(inApp to Milliseconds(0L)) } + } + + inAppMessageManager.listenEventAndInApp() + advanceUntilIdle() + + assertEquals(tags, capturedTags) + capturedCallbacks!!.onInAppClick.onClick() + verify(exactly = 1) { inAppMessageInteractor.sendInAppClicked(inAppMessage.inAppId, tags) } + } + + @Test + fun `tags feature off - tags are null for show and click`() = runTest { + val inAppToShowFlow = MutableSharedFlow>() + val inApp = InAppStub.getInApp().copy(tags = mapOf("templateType" to "Popup")) + val inAppMessage = inApp.form.variants.first() + var capturedCallbacks: InAppActionCallbacks? = null + var capturedTags: Map? = mapOf("sentinel" to "value") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns false + every { inAppMessageViewDisplayer.isInAppActive() } returns false + every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns true + every { inAppMessageInteractor.sendInAppClicked(any(), any()) } just runs + every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow + every { inAppMessageDelayedManager.process(inApp, any()) } coAnswers { + this@runTest.launch { inAppToShowFlow.emit(inApp to Milliseconds(0L)) } + } + every { inAppMessageViewDisplayer.tryShowInAppMessage(any(), any(), any(), any()) } answers { + capturedCallbacks = arg(1) + capturedTags = arg(3) + } + inAppMessageManager = InAppMessageManagerImpl( + inAppMessageViewDisplayer, + inAppMessageInteractor, + testDispatcher, + monitoringRepository, + sessionStorageManager, + userVisitManager, + inAppMessageDelayedManager, + timeProvider, + featureToggleManager + ) + coEvery { inAppMessageInteractor.processEventAndConfig() } answers { + flow { emit(inApp to Milliseconds(0L)) } + } + + inAppMessageManager.listenEventAndInApp() + advanceUntilIdle() + + assertEquals(null, capturedTags) + capturedCallbacks!!.onInAppClick.onClick() + verify(exactly = 1) { inAppMessageInteractor.sendInAppClicked(inAppMessage.inAppId, null) } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt index 6aa83190..bc294f90 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt @@ -1,12 +1,16 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.ComposableInAppCallback +import cloud.mindbox.mobile_sdk.inapp.presentation.view.InAppViewHolder +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import com.google.gson.Gson import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll +import io.mockk.verify import org.junit.After import org.junit.Assert.assertNotSame import org.junit.Assert.assertSame @@ -17,12 +21,15 @@ import org.junit.Test internal class InAppMessageViewDisplayerImplTest { private lateinit var displayer: InAppMessageViewDisplayerImpl + private lateinit var failureTracker: InAppFailureTracker @Before fun setUp() { mockkObject(MindboxDI) - every { MindboxDI.appModule } returns mockk { + failureTracker = mockk(relaxed = true) + every { MindboxDI.appModule } returns mockk(relaxed = true) { every { gson } returns Gson() + every { inAppFailureTracker } returns failureTracker } displayer = InAppMessageViewDisplayerImpl(mockk()) } @@ -84,10 +91,47 @@ internal class InAppMessageViewDisplayerImplTest { assertTrue(displayer.currentCallback() is ComposableInAppCallback) } + @Test + fun `reattach presentation failure propagates restored holder tags`() { + val expectedTags = mapOf("templateType" to "Popup", "campaign" to "summer") + val inAppId = "reattach-inapp-id" + + val restoredHolder = mockk>(relaxed = true) + every { restoredHolder.canReuseOnRestore(inAppId) } returns true + every { restoredHolder.wrapper.tags } returns expectedTags + + // pausedHolder present, currentActivity stays null -> root is null -> reattach failure branch + displayer.setPrivateField("pausedHolder", restoredHolder) + + val reattached = displayer.invokePrivateWithString("tryReattachRestoredInApp", inAppId) as Boolean + + assertTrue("reattach should be attempted for a reusable paused holder", reattached) + verify(exactly = 1) { + failureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "failed to reattach inApp: currentRoot is null", + tags = expectedTags, + ) + } + } + // Accesses the private inAppCallback field via reflection private fun InAppMessageViewDisplayerImpl.currentCallback(): InAppCallback { val field = InAppMessageViewDisplayerImpl::class.java.getDeclaredField("inAppCallback") field.isAccessible = true return field.get(this) as InAppCallback } + + private fun InAppMessageViewDisplayerImpl.setPrivateField(name: String, value: Any?) { + val field = InAppMessageViewDisplayerImpl::class.java.getDeclaredField(name) + field.isAccessible = true + field.set(this, value) + } + + private fun InAppMessageViewDisplayerImpl.invokePrivateWithString(name: String, arg: String): Any? { + val method = InAppMessageViewDisplayerImpl::class.java.getDeclaredMethod(name, String::class.java) + method.isAccessible = true + return method.invoke(this, arg) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt index ee49e289..4138f21f 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Application import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.MindboxError +import com.google.gson.Gson import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.After @@ -17,7 +18,7 @@ class WebViewOperationExecutorTest { @Before fun onTestStart() { - executor = MindboxWebViewOperationExecutor() + executor = MindboxWebViewOperationExecutor(Gson()) mockkObject(MindboxEventManager) } @@ -31,7 +32,7 @@ class WebViewOperationExecutorTest { val context: Application = mockk() val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit - executor.executeAsyncOperation(context, payload) + executor.executeAsyncOperation(context, payload, tags = null) verify(exactly = 1) { MindboxEventManager.asyncOperation( context = context, @@ -41,12 +42,122 @@ class WebViewOperationExecutorTest { } } + @Test + fun `executeAsyncOperation adds top-level tags to body when tags present`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"Popup"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation does not add tags when tags empty`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = emptyMap()) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home"}""", + ) + } + } + + @Test + fun `executeAsyncOperation adds in-app tags when existing tags is json null`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":null}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"Popup"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation merges in-app tags into existing tags without collision`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":{"client":"own"}}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"client":"own","templateType":"Popup"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation keeps client value and skips in-app value on key collision`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":{"templateType":"ClientOwn"}}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"ClientOwn"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation merges only non-colliding keys when tags partially overlap`() { + val context: Application = mockk() + val payload: String = + """{"operation":"OpenScreen","body":{"screen":"home","tags":{"templateType":"ClientOwn","keep":"x"}}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation( + context, + payload, + tags = mapOf("templateType" to "Popup", "campaign" to "summer"), + ) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"ClientOwn","keep":"x","campaign":"summer"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation keeps client tags untouched when existing tags is not an object`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":"raw"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":"raw"}""", + ) + } + } + @Test fun `executeAsyncOperation throws when payload misses operation`() { val context: Application = mockk() val payload: String = """{"body":{"screen":"home"}}""" try { - executor.executeAsyncOperation(context, payload) + executor.executeAsyncOperation(context, payload, tags = null) fail("Expected IllegalArgumentException") } catch (exception: IllegalArgumentException) { assertEquals("Operation is not provided", exception.message) @@ -59,7 +170,7 @@ class WebViewOperationExecutorTest { val context: Application = mockk() val payload: String = """{"operation":"OpenScreen"}""" try { - executor.executeAsyncOperation(context, payload) + executor.executeAsyncOperation(context, payload, tags = null) fail("Expected IllegalArgumentException") } catch (exception: IllegalArgumentException) { assertEquals("Body is not provided", exception.message) @@ -68,15 +179,27 @@ class WebViewOperationExecutorTest { } @Test - fun `executeAsyncOperation throws when payload is invalid json empty or null`() { + fun `executeAsyncOperation throws IllegalArgumentException when payload is null`() { val context: Application = mockk() - val payloads: List = listOf("not-json", "", null) - payloads.forEach { payload: String? -> + try { + executor.executeAsyncOperation(context, payload = null, tags = null) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not provided", exception.message) + } + verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } + } + + @Test + fun `executeAsyncOperation throws IllegalArgumentException when payload is invalid json`() { + val context: Application = mockk() + val payloads: List = listOf("not-json", "") + payloads.forEach { payload: String -> try { - executor.executeAsyncOperation(context, payload) - fail("Expected exception for payload: $payload") - } catch (exception: Exception) { - // Expected: payload cannot be parsed to required JSON object. + executor.executeAsyncOperation(context, payload, tags = null) + fail("Expected IllegalArgumentException for payload: $payload") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not a valid JSON object", exception.message) } } verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } @@ -97,7 +220,7 @@ class WebViewOperationExecutorTest { val onSuccess: (String) -> Unit = arg(2) onSuccess(expectedResponse) } - val actualResponse: String = executor.executeSyncOperation(payload) + val actualResponse: String = executor.executeSyncOperation(payload, tags = null) assertEquals(expectedResponse, actualResponse) verify(exactly = 1) { MindboxEventManager.syncOperation( @@ -109,6 +232,64 @@ class WebViewOperationExecutorTest { } } + @Test + fun `executeSyncOperation adds top-level tags and still returns response`() = runTest { + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + val expectedResponse: String = """{"result":"ok"}""" + every { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } answers { + val onSuccess: (String) -> Unit = arg(2) + onSuccess(expectedResponse) + } + val actualResponse: String = executor.executeSyncOperation(payload, tags = mapOf("templateType" to "Popup")) + assertEquals(expectedResponse, actualResponse) + verify(exactly = 1) { + MindboxEventManager.syncOperation( + name = "OpenScreen", + bodyJson = """{"screen":"home","tags":{"templateType":"Popup"}}""", + onSuccess = any(), + onError = any(), + ) + } + } + + @Test + fun `executeSyncOperation merges tags keeping client value on collision and still returns response`() = runTest { + val payload: String = + """{"operation":"OpenScreen","body":{"screen":"home","tags":{"templateType":"ClientOwn"}}}""" + val expectedResponse: String = """{"result":"ok"}""" + every { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } answers { + val onSuccess: (String) -> Unit = arg(2) + onSuccess(expectedResponse) + } + val actualResponse: String = executor.executeSyncOperation( + payload, + tags = mapOf("templateType" to "Popup", "campaign" to "summer"), + ) + assertEquals(expectedResponse, actualResponse) + verify(exactly = 1) { + MindboxEventManager.syncOperation( + name = "OpenScreen", + bodyJson = """{"screen":"home","tags":{"templateType":"ClientOwn","campaign":"summer"}}""", + onSuccess = any(), + onError = any(), + ) + } + } + @Test fun `executeSyncOperation throws IllegalStateException when event manager returns error`() = runTest { val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" @@ -125,7 +306,7 @@ class WebViewOperationExecutorTest { onError(expectedError) } try { - executor.executeSyncOperation(payload) + executor.executeSyncOperation(payload, tags = null) fail("Expected IllegalStateException") } catch (exception: IllegalStateException) { assertEquals(expectedError.toJson(), exception.message) @@ -136,7 +317,7 @@ class WebViewOperationExecutorTest { fun `executeSyncOperation throws when payload misses body`() = runTest { val payload: String = """{"operation":"OpenScreen"}""" try { - executor.executeSyncOperation(payload) + executor.executeSyncOperation(payload, tags = null) fail("Expected IllegalArgumentException") } catch (exception: IllegalArgumentException) { assertEquals("Body is not provided", exception.message) @@ -152,14 +333,32 @@ class WebViewOperationExecutorTest { } @Test - fun `executeSyncOperation throws when payload is invalid json empty or null`() = runTest { - val payloads: List = listOf("not-json", "", null) - payloads.forEach { payload: String? -> + fun `executeSyncOperation throws IllegalArgumentException when payload is null`() = runTest { + try { + executor.executeSyncOperation(payload = null, tags = null) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not provided", exception.message) + } + verify(exactly = 0) { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } + } + + @Test + fun `executeSyncOperation throws IllegalArgumentException when payload is invalid json`() = runTest { + val payloads: List = listOf("not-json", "") + payloads.forEach { payload: String -> try { - executor.executeSyncOperation(payload) - fail("Expected exception for payload: $payload") - } catch (exception: Exception) { - // Expected: payload cannot be parsed to required JSON object. + executor.executeSyncOperation(payload, tags = null) + fail("Expected IllegalArgumentException for payload: $payload") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not a valid JSON object", exception.message) } } verify(exactly = 0) { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequestStub.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequestStub.kt deleted file mode 100644 index 0249c78c..00000000 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequestStub.kt +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.mindbox.mobile_sdk.models.operation.request - -internal class InAppHandleRequestStub { - companion object { - fun get() = InAppHandleRequest(inAppId = "") - } -} From 15741ddb916a8042b70ad1c2ceb7b90438d37ce2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 06:28:37 +0000 Subject: [PATCH 27/37] deps(deps): bump actions/setup-java in the github-actions group (#734) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lint_unitTests_build.yml | 6 +++--- .github/workflows/publish-reusable.yml | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index 87c08f7a..0cad4984 100644 --- a/.github/workflows/lint_unitTests_build.yml +++ b/.github/workflows/lint_unitTests_build.yml @@ -24,7 +24,7 @@ jobs: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '17' @@ -75,7 +75,7 @@ jobs: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '17' @@ -125,7 +125,7 @@ jobs: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/publish-reusable.yml b/.github/workflows/publish-reusable.yml index 8ae7fcdf..c7814c46 100644 --- a/.github/workflows/publish-reusable.yml +++ b/.github/workflows/publish-reusable.yml @@ -35,7 +35,7 @@ jobs: submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '17' @@ -54,7 +54,7 @@ jobs: submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '17' @@ -74,7 +74,7 @@ jobs: submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '17' @@ -119,7 +119,7 @@ jobs: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '17' From 5174816dccf716bfe8ed24ededd51fbf1fb21812 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:36:45 +0300 Subject: [PATCH 28/37] MOBILE-269: Webview cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MOBILE-0000: Bump kmp submodule (drain evaluate callbacks before destroy) MOBILE-0000: Cover the stranded-preconnect release and cap budget with tests MOBILE-0000: Release the stranded preconnect WebView and honest-cap the settle poll A no-layers config landing while the init prewarm was suspended latched the verdict but the resumed prewarm bare-returned after the fetch: the preconnect load had already created a WebView and no settle poll would ever free it. The verdict is now re-checked before the preconnect load and the post-fetch path releases. Also: null probes are charged the probe timeout so the settle cap is a real wall-clock bound; ready-check give-up defers to the init timer instead of closing; teardown-race evaluate misses no longer feed telemetry; GatewayManager is injected lazily (Volley was constructed on the main thread during SDK init); learned-hosts merge is synchronized; kmp submodule bump (destroy fix, prewarm navigation pinning, httpsOrigin charset). MOBILE-0000: Cover the settle-poll guards, one-shot survival and hard cap MOBILE-0000: Harden the prewarm service and show holder after the audit - the settle poll checks the terminal-preempt latch before it is stored and on every tick — a real show could otherwise inherit a 30s zombie poll in the slot onRealShowWillStart() just cleared; - a no-layers config latches: an init prewarm still suspended in the content fetch can no longer resurrect a WebView the config retired; - the one-shot is consumed only by attempts that reach the engine, so a transient configuration read failure cannot block a later valid warm; - the poll budget is counted in ticks, not wall clock (same 30s, fully virtual-time testable); - onClose detaches the event listener and startReadyCheck refuses a torn-down holder — a late onPageFinished queued on the main looper produced a spurious presentation failure and a second close; - controller.destroy() is called once, not twice; - checkEvaluateJavaScript tracks a failed outgoing call but no longer force-closes the in-app — that is the caller's policy, and one transient miss used to tear down a healthy show. MOBILE-0000: Cover the network-idle settle release MOBILE-0000: Release the prewarm WebView by network idle, not a blind timer The hidden instance lived a fixed 30s after the content page load; a Resource Timing poll (stable entry count + 2s quiet window) releases it as soon as the page has settled — ~3s in practice. The 30s stays as a hard cap: entries appear only on completion, so a transfer slower than the quiet window can still be cut, same worst case as the cap. MOBILE-0000: Cover the WebView readiness probe retry MOBILE-0000: Retry the WebView readiness probe before closing the in-app onPageFinished can fire before the page's module scripts have evaluated (slow device, cold cache), and the single-shot bridge check then closed a healthy in-app. The probe now polls briefly (WebViewReadyChecker, mirrors the iOS fix); the outgoing-message verification stays single-shot — that path talks to a page that already proved itself ready. MOBILE-0000: Update prewarm service tests for the stub-less engine MOBILE-0000: Drop the legacy prewarm stub bridge Bumps kmp-common: the engine loads the content page without the stub JavascriptInterface; old pages degrade to a plain page warm until the web prewarm mode is deployed. MOBILE-0000: Update prewarm service tests for the params contract MOBILE-0000: Load the prewarm content page with official prewarm params A web runtime that knows the contract boots tracker-only from the document URL params; older runtimes ignore them and keep using the engine's legacy stub bridge. Bumps kmp-common with prewarmContentBaseUrl and the stub-call logging. MOBILE-0000: Preempt prewarm terminally + thread-safe settle job Real-show preemption now calls engine.abort() (synchronous latch closes the race where an already-posted prewarm load recreated the WebView mid-show and, with the settle job cancelled, kept it alive forever). settleJob becomes an AtomicReference. Bump kmp-common. MOBILE-0000: Gate prewarm light parse by sdkVersion like the real pipeline The endpoint config carries webview-typed form variants for newer SDK versions; without the version gate the head-start parse logs a JsonParseException per such in-app on every launch. MOBILE-0000: Wire production WebView prewarm service into SDK lifecycle InAppWebViewPrewarmService: stage 1 at init (head start from cached config, light layer parse), stage 2 after each config parse in MobileConfigRepositoryImpl (release when no webview in-apps), preemption from WebViewInAppViewHolder before a real show, learned-hosts capture at bridge close feeding next launch's preconnect. Everything derived from config + API domain; no hardcoded hosts. Bump kmp-common with cache/prewarm primitives. MOBILE-0000: Prewarm WebView at SDK init + bump kmp-common Call MindboxWebViewLab.prewarm() from Mindbox.initialize() to warm the renderer process/connections early. Bump kmp-common-sdk submodule to the prototype branch carrying the cache+preconnect prototype. Throwaway measurement scaffolding. --- .gitmodules | 2 +- kmp-common-sdk | 2 +- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 3 + .../mobile_sdk/di/modules/DataModule.kt | 23 +- .../mobile_sdk/di/modules/MindboxModule.kt | 1 + .../managers/InAppWebViewLearnedHostsStore.kt | 44 +++ .../MobileConfigRepositoryImpl.kt | 7 +- .../InAppWebViewPrewarmService.kt | 353 +++++++++++++++++ .../view/WebViewInappViewHolder.kt | 81 +++- .../presentation/view/WebViewReadyChecker.kt | 66 ++++ .../InAppWebViewLearnedHostsStoreTest.kt | 57 +++ .../MobileConfigRepositoryImplTest.kt | 3 +- .../InAppWebViewPrewarmServiceImplTest.kt | 373 ++++++++++++++++++ .../view/WebViewReadyCheckerTest.kt | 106 +++++ 14 files changed, 1109 insertions(+), 12 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt diff --git a/.gitmodules b/.gitmodules index 8112ba12..db7f9ca9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "kmp-common-sdk"] path = kmp-common-sdk url = https://github.com/mindbox-cloud/kmp-common-sdk.git - branch = develop + branch = prototype/webview-cache-profiling diff --git a/kmp-common-sdk b/kmp-common-sdk index 80e199ff..19df8a93 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 80e199ff1d25f1bed6283287b4a89be6e3e65e10 +Subproject commit 19df8a9382530d74d1d14e52c580ebdc3c08f92f diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 7a6bbdf3..b163c673 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -562,6 +562,9 @@ public object Mindbox : MindboxLog { Stopwatch.start(Stopwatch.INIT_SDK) initComponents(context.applicationContext) + // Prewarm stage 1: head start for webview in-apps from the cached config + // (mirrors the iOS SDK's init-time prewarm; a real show always preempts it). + MindboxDI.appModule.inAppWebViewPrewarmService.prewarmOnInit() pushConverters = selectPushServiceHandler(pushServices) logI( "init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 6695ffa4..92f5225a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -26,9 +26,13 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSer import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmServiceImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmEngine +import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.managers.* import cloud.mindbox.mobile_sdk.managers.MobileConfigSettingsManagerImpl import cloud.mindbox.mobile_sdk.managers.RequestPermissionManager @@ -145,6 +149,22 @@ internal fun DataModule( ) } + override val inAppWebViewPrewarmService: InAppWebViewPrewarmService by lazy { + InAppWebViewPrewarmServiceImpl( + engine = InAppWebViewPrewarmEngine(appContext) { message -> + mindboxLogI("[WebView] Prewarm: $message") + }, + mobileConfigSerializationManager = mobileConfigSerializationManager, + // Lazy on purpose: this service is resolved on the main thread during SDK init + // (prewarm stage 1), and constructing GatewayManager spins up Volley (thread + // pool + cache-dir I/O) — defer that to the background fetch that needs it. + gatewayManager = lazy { gatewayManager }, + inAppValidator = inAppValidator, + webViewLayerValidator = webViewLayerValidator, + learnedHostsStore = InAppWebViewLearnedHostsStore() + ) + } + override val mobileConfigRepository: MobileConfigRepository by lazy { MobileConfigRepositoryImpl( inAppMapper = inAppMapper, @@ -163,7 +183,8 @@ internal fun DataModule( mobileConfigSettingsManager = mobileConfigSettingsManager, integerPositiveValidator = integerPositiveValidator, inappSettingsManager = inappSettingsManager, - featureToggleManager = featureToggleManager + featureToggleManager = featureToggleManager, + inAppWebViewPrewarmService = inAppWebViewPrewarmService ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index 4d5f8499..f03e5746 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -64,6 +64,7 @@ internal interface DataModule : MindboxModule { val sessionStorageManager: SessionStorageManager val mobileConfigRepository: MobileConfigRepository val mobileConfigSerializationManager: MobileConfigSerializationManager + val inAppWebViewPrewarmService: InAppWebViewPrewarmService val inAppGeoRepository: InAppGeoRepository val inAppRepository: InAppRepository val callbackRepository: CallbackRepository diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt new file mode 100644 index 00000000..b4f8fd58 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStore.kt @@ -0,0 +1,44 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import cloud.mindbox.mobile_sdk.managers.SharedPreferencesManager +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import org.json.JSONArray + +/** + * Persists https hosts actually observed during webview in-app shows (per endpoint). + * The mobile config only reveals the bootstrap hosts; the heavy ones (image CDNs) + * are discovered at show time and fed to the next launch's preconnect prewarm. + */ +internal class InAppWebViewLearnedHostsStore { + + companion object { + private const val KEY_PREFIX = "MBInAppWebViewLearnedHosts." + private const val MAX_HOSTS = 12 + } + + fun hosts(endpointId: String): List = loggingRunCatching(defaultValue = emptyList()) { + val raw = SharedPreferencesManager.getString(key(endpointId)) + ?.takeIf { it.isNotBlank() } + ?: return@loggingRunCatching emptyList() + val array = JSONArray(raw) + (0 until array.length()).mapNotNull { index -> + array.optString(index).takeIf { host -> host.isNotBlank() } + } + } + + /** + * Newest-first merge capped at [MAX_HOSTS] so one weird show can't flood the list. + * Synchronized: merges arrive from evaluate callbacks/coroutines of concurrent closes, + * and an unsynchronized read-modify-write would silently drop one close's hosts. + */ + @Synchronized + fun merge(endpointId: String, observedHosts: List): Unit = loggingRunCatching { + if (endpointId.isBlank()) return@loggingRunCatching + val incoming = observedHosts.map(String::trim).filter(String::isNotBlank) + if (incoming.isEmpty()) return@loggingRunCatching + val merged = (incoming + hosts(endpointId)).distinct().take(MAX_HOSTS) + SharedPreferencesManager.put(key(endpointId), JSONArray(merged).toString()) + } + + private fun key(endpointId: String): String = KEY_PREFIX + endpointId +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 42abb68d..8c1a13ae 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -13,6 +13,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTtlData +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI @@ -51,7 +52,8 @@ internal class MobileConfigRepositoryImpl( private val mobileConfigSettingsManager: MobileConfigSettingsManager, private val integerPositiveValidator: IntegerPositiveValidator, private val inappSettingsManager: InappSettingsManager, - private val featureToggleManager: FeatureToggleManager + private val featureToggleManager: FeatureToggleManager, + private val inAppWebViewPrewarmService: InAppWebViewPrewarmService ) : MobileConfigRepository { private val mutex = Mutex() @@ -105,6 +107,9 @@ internal class MobileConfigRepositoryImpl( featureToggleManager.applyToggles(config = filteredConfig) persistOperationsDomain(filteredConfig) configState.value = updatedInAppConfig + // Prewarm stage 2: warm what the config's webview in-apps will need + // (or release the warm instance when the config proves there are none). + inAppWebViewPrewarmService.prewarmResources(updatedInAppConfig) mindboxLogI(message = "Providing config: $updatedInAppConfig") } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt new file mode 100644 index 00000000..e4ea0e39 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt @@ -0,0 +1,353 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.InAppWebViewLearnedHostsStore +import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmEngine +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmLayer +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmPlanner +import cloud.mindbox.mobile_sdk.inapp.webview.MindboxWebViewLab +import cloud.mindbox.mobile_sdk.inapp.webview.WebViewController +import cloud.mindbox.mobile_sdk.logger.mindboxLogI +import cloud.mindbox.mobile_sdk.logger.mindboxLogW +import cloud.mindbox.mobile_sdk.managers.DbManager +import cloud.mindbox.mobile_sdk.managers.GatewayManager +import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.getShortUserAgent +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.loggingRunCatching +import cloud.mindbox.mobile_sdk.utils.loggingRunCatchingSuspending +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray +import org.json.JSONTokener +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * Production prewarm for webview in-apps. Two stages, both driven by the mobile + * config (no hardcoded hosts or URLs): + * + * 1. SDK init — head start from the previous launch's cached config; + * 2. config downloaded/parsed — [prewarmResources] (releases the warm instance + * when the config proves there are no webview in-apps). + * + * The resource prewarm loads a preconnect page (origins from the config layers + + * API domain + hosts learned from previous shows) and then the layer's real content + * page with the official prewarm params on its URL, so the shared HTTP cache and + * connection pool are warm before the first show. A real show always preempts the prewarm + * ([onRealShowWillStart]): the hidden WebView is destroyed and the network is + * handed over. Unlike iOS there is no instance reuse — Android shares the renderer + * process, so a warm instance buys nothing (measured). + */ +internal interface InAppWebViewPrewarmService { + + /** Prewarm stage 1: head start from the cached config. Call once at SDK init. */ + fun prewarmOnInit() + + /** Prewarm stage 2: warm what [config]'s webview in-apps need (or release when none). */ + fun prewarmResources(config: InAppConfig) + + /** + * A real WEBVIEW show is starting: abort the prewarm and free its WebView. + * + * Deliberately narrow — image/snackbar shows do not preempt: their downloads are small + * next to the settle window (network-idle release frees the WebView within seconds), + * while aborting here is terminal and would forfeit the whole launch's byendpoint warm + * because an unrelated banner happened to show first. + */ + fun onRealShowWillStart() + + /** Records the https hosts the shown page actually used (learned-hosts store). */ + fun captureObservedHosts(controller: WebViewController) +} + +@OptIn(InternalMindboxApi::class) +internal class InAppWebViewPrewarmServiceImpl( + private val engine: InAppWebViewPrewarmEngine, + private val mobileConfigSerializationManager: MobileConfigSerializationManager, + private val gatewayManager: Lazy, + private val inAppValidator: InAppValidator, + private val webViewLayerValidator: WebViewLayerValidator, + private val learnedHostsStore: InAppWebViewLearnedHostsStore +) : InAppWebViewPrewarmService { + + companion object { + // Hard cap on how long the hidden WebView may live after the content page was + // handed to it; normally the network-idle poll below releases it much earlier + // (cache/sockets survive at the profile level, so keeping it alive buys nothing). + private const val SETTLE_RELEASE_MS = 30_000L + + // Network-idle release: the page is considered settled when the Resource Timing + // entry count is stable across consecutive polls AND the last completed resource + // finished at least IDLE_QUIET_MS ago. Entries appear only on completion, so the + // quiet window (not the stable count alone) is what guards against an in-flight + // download; a transfer slower than the window can still be cut — same worst case + // as the hard cap, the show just re-fetches that file. + private const val IDLE_POLL_MS = 1_000L + private const val IDLE_QUIET_MS = 2_000L + private const val IDLE_STABLE_POLLS = 2 + + // Returns ":" (or "" on any error). + private const val IDLE_PROBE_JS = + "(function(){try{var e=performance.getEntriesByType('resource');var l=0;" + + "for(var i=0;il)l=r}" + + "return e.length+':'+Math.round(performance.now()-l)}catch(t){return''}})()" + } + + private val hasStartedResourcePrewarm = AtomicBoolean(false) + private val hasAborted = AtomicBoolean(false) + + // Set when the freshest config proved there is nothing to warm: an init prewarm still + // suspended in the content fetch must not resurrect a WebView the config just retired. + private val latestConfigHasNoLayers = AtomicBoolean(false) + private val settleJob = AtomicReference(null) + + override fun prewarmOnInit() { + if (!MindboxWebViewLab.PREWARM_ENABLED) return // MEASUREMENT (throwaway) gate + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + val cachedConfig = MindboxPreferences.inAppConfig + if (cachedConfig.isBlank()) return@loggingRunCatchingSuspending + val layers = webViewLayers(cachedConfig) + if (layers.isEmpty()) return@loggingRunCatchingSuspending + mindboxLogI("[WebView] Prewarm: head start from cached config (${layers.size} webview layer(s))") + startResourcePrewarm(layers) + } + } + } + + override fun prewarmResources(config: InAppConfig) { + if (!MindboxWebViewLab.PREWARM_ENABLED) return // MEASUREMENT (throwaway) gate + val layers = config.inApps + .flatMap { inApp -> inApp.form.variants } + .filterIsInstance() + .flatMap { webView -> webView.layers } + .filterIsInstance() + .map { layer -> InAppWebViewPrewarmLayer(baseUrl = layer.baseUrl, contentUrl = layer.contentUrl) } + if (layers.isEmpty()) { + mindboxLogI("[WebView] Prewarm: config has no webview in-apps, releasing warm WebView") + latestConfigHasNoLayers.set(true) + settleJob.getAndSet(null)?.cancel() + engine.release() + return + } + latestConfigHasNoLayers.set(false) + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + startResourcePrewarm(layers) + } + } + } + + override fun onRealShowWillStart() { + hasAborted.set(true) + settleJob.getAndSet(null)?.cancel() + // Terminal: the engine latches synchronously, so a prewarm load already posted from + // a background thread cannot resurrect the WebView mid-show. + engine.abort() + } + + override fun captureObservedHosts(controller: WebViewController) { + controller.evaluateJavaScript(InAppWebViewPrewarmPlanner.observedResourceHostsScript) { result -> + val observedHosts = parseObservedHosts(result) + if (observedHosts.isEmpty()) return@evaluateJavaScript + Mindbox.mindboxScope.launch { + loggingRunCatchingSuspending { + val endpointId = currentConfiguration()?.endpointId ?: return@loggingRunCatchingSuspending + learnedHostsStore.merge(endpointId, observedHosts) + mindboxLogI("[WebView] Prewarm: learned hosts for $endpointId: $observedHosts") + } + } + } + } + + /** + * Runs at most once per process — but only an attempt that actually reaches the engine + * consumes the one-shot: a transient configuration read failure or an unplannable + * cached config must not block a later attempt from a valid fresh config. + * + * By design the one-shot also means stage 2 does NOT re-warm when stage 1 already ran + * from a cached config whose URLs have since changed — the head start beats freshness + * for this launch, and the next launch heals with the new cached config. + */ + private suspend fun startResourcePrewarm(layers: List) { + if (hasAborted.get()) return + + val configuration = currentConfiguration() ?: run { + mindboxLogW("[WebView] Prewarm: no saved configuration, skipping") + return + } + val plan = InAppWebViewPrewarmPlanner.buildPlan( + layers = layers, + extraOrigins = listOf(configuration.domain) + learnedHostsStore.hosts(configuration.endpointId) + ) ?: run { + mindboxLogW("[WebView] Prewarm: no valid webview layer urls in config, skipping") + return + } + if (!hasStartedResourcePrewarm.compareAndSet(false, true)) return + // Re-check after the configuration read suspension: a fresh no-layers config that + // landed while this (init-triggered) prewarm was suspended must keep the WebView + // from ever being created — release() posted at that point was a no-op. + if (hasAborted.get() || latestConfigHasNoLayers.get()) return + val userAgentSuffix = configuration.getShortUserAgent() + + mindboxLogI("[WebView] Prewarm: preconnect to ${plan.preconnectOrigins.joinToString(",")} under ${plan.baseUrl}") + engine.loadPreconnectPage(plan.preconnectHtml, plan.baseUrl, userAgentSuffix) + + if (MindboxWebViewLab.PREWARM_PRECONNECT_ONLY) { // MEASUREMENT (throwaway) gate + scheduleSettleRelease() + return + } + + val html = runCatching { gatewayManager.value.fetchWebViewContent(plan.contentUrl) } + .getOrElse { error -> + mindboxLogW("[WebView] Prewarm: content page fetch failed: $error") + scheduleSettleRelease() + return + } + // Re-check both verdicts after the suspension point: a real show may have taken the + // network over, or a fresh config may have proven there is nothing to warm — either + // way the fetched content must not resurrect a WebView. The no-layers path must + // also RELEASE: the preconnect load above may have already created the WebView, and + // with no settle poll scheduled on this path nothing else would ever free it. + if (hasAborted.get()) return + if (latestConfigHasNoLayers.get()) { + engine.release() + return + } + // Official prewarm contract on the document URL: a runtime that knows it boots + // tracker-only; an older runtime ignores it (plain page warm, no byendpoint). + val prewarmBaseUrl = InAppWebViewPrewarmPlanner.prewarmContentBaseUrl( + baseUrl = plan.baseUrl, + endpointId = configuration.endpointId, + deviceUuid = MindboxPreferences.deviceUuid + ) + mindboxLogI("[WebView] Prewarm: content page under $prewarmBaseUrl, endpoint ${configuration.endpointId}") + engine.loadContentPage( + html = html, + baseUrl = prewarmBaseUrl, + userAgentSuffix = userAgentSuffix + ) + scheduleSettleRelease() + } + + private fun scheduleSettleRelease() { + // A terminal preempt may have landed while the caller was suspended — never store a + // poll job into the slot onRealShowWillStart() just cleared (it would probe the main + // looper for 30s during the live show). + if (hasAborted.get()) return + val job = Mindbox.mindboxScope.launch { + // Budget in poll units instead of a wall clock read: the whole loop runs on + // virtual time in tests. A null probe is charged the full probe timeout so the + // cap stays a real wall-clock bound even when the evaluate callback never fires + // (blocked main thread, renderer stall — the pathological cases the cap exists + // for). A fast-but-garbage probe gets overcharged and releases early, which is + // the safe direction: garbage means the page or WebView is not answering. + val budgetPolls = (SETTLE_RELEASE_MS / IDLE_POLL_MS).toInt() + val timeoutCharge = (IDLE_QUIET_MS / IDLE_POLL_MS).toInt() + var polls = 0 + var lastCount = -1 + var stablePolls = 0 + var idle = false + while (polls < budgetPolls) { + delay(IDLE_POLL_MS) + polls++ + if (hasAborted.get()) return@launch + val probe = probeResourceState() + if (probe == null) { + polls += timeoutCharge + continue + } + if (probe.entryCount == lastCount) { + stablePolls++ + } else { + stablePolls = 0 + lastCount = probe.entryCount + } + if (stablePolls >= IDLE_STABLE_POLLS && probe.msSinceLastResponseEnd > IDLE_QUIET_MS) { + idle = true + break + } + } + mindboxLogI( + "[WebView] Prewarm: settle release after ~${polls * IDLE_POLL_MS}ms of budget " + + if (idle) "(network idle, $lastCount resources)" else "(hard cap)" + ) + engine.release() + } + settleJob.getAndSet(job)?.cancel() + } + + private data class ResourceProbe(val entryCount: Int, val msSinceLastResponseEnd: Long) + + /** + * One Resource Timing probe on the prewarm page. Null when the probe cannot run or + * returns garbage (WebView gone/aborted, page not ready) — callers just keep polling + * until the hard cap. The 2s timeout guards against a callback that never fires. + */ + private suspend fun probeResourceState(): ResourceProbe? = + withTimeoutOrNull(IDLE_QUIET_MS) { + suspendCancellableCoroutine { continuation -> + engine.evaluateJavaScript(IDLE_PROBE_JS) { rawResult -> + // evaluateJavascript JSON-quotes string results: "\"6:3456\"". + val parts = rawResult?.trim('"')?.split(':') + val probe = if (parts?.size == 2) { + val count = parts[0].toIntOrNull() + val sinceLast = parts[1].toLongOrNull() + if (count != null && sinceLast != null) ResourceProbe(count, sinceLast) else null + } else { + null + } + if (continuation.isActive) continuation.resume(probe) {} + } + } + } + + private suspend fun currentConfiguration(): Configuration? = + runCatching { DbManager.listenConfigurations().first() }.getOrNull() + + /** Light parse of the cached config: only webview layer urls, no targeting checks. */ + private fun webViewLayers(configString: String): List { + val configBlank = mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) + ?: return emptyList() + return configBlank.inApps.orEmpty() + // Same version gate as the real pipeline: in-apps for other SDK versions may + // carry form formats this version cannot even deserialize. + .filter { inAppBlank -> inAppValidator.validateInAppVersion(inAppBlank) } + .flatMap { inAppBlank -> + mobileConfigSerializationManager.deserializeToInAppFormDto(inAppBlank.form) + ?.variants.orEmpty() + .filterIsInstance() + .flatMap { modal -> modal.content?.background?.layers.orEmpty() } + } + .filterIsInstance() + .filter { layerDto -> webViewLayerValidator.isValid(layerDto) } + .map { layerDto -> InAppWebViewPrewarmLayer(baseUrl = layerDto.baseUrl, contentUrl = layerDto.contentUrl) } + } + + /** + * `evaluateJavascript` returns the JS value JSON-encoded; the probe returns a string + * containing a JSON array, so unwrap the outer string and then parse the array. + */ + private fun parseObservedHosts(result: String?): List = loggingRunCatching(defaultValue = emptyList()) { + if (result.isNullOrBlank() || result == "null") return@loggingRunCatching emptyList() + val unwrapped = JSONTokener(result).nextValue() as? String ?: return@loggingRunCatching emptyList() + val array = JSONArray(unwrapped) + (0 until array.length()).mapNotNull { index -> + array.optString(index).takeIf { host -> host.isNotBlank() } + } + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 31cdb714..5a0e42fb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -3,6 +3,8 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Activity import android.app.Application import android.net.Uri +import android.os.Handler +import android.os.Looper import android.view.ViewGroup import android.widget.RelativeLayout import android.widget.Toast @@ -22,6 +24,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import androidx.lifecycle.ProcessLifecycleOwner @@ -77,6 +80,8 @@ internal class WebViewInAppViewHolder( private var closeInappTimer: Timer? = null private var webViewController: WebViewController? = null private var currentWebViewOrigin: String? = null + private var readyChecker: WebViewReadyChecker? = null + private val mainHandler: Handler = Handler(Looper.getMainLooper()) private var motionService: MotionServiceProtocol? = null @@ -89,6 +94,7 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val timeProvider: TimeProvider by mindboxInject { timeProvider } + private val webViewPrewarmService: InAppWebViewPrewarmService by mindboxInject { inAppWebViewPrewarmService } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -317,6 +323,8 @@ internal class WebViewInAppViewHolder( private fun handleCloseAction(message: BridgeMessage): String { motionService?.stopMonitoring() + // Remember which https hosts this show actually used — feeds the next launch's preconnect. + webViewController?.let { controller -> webViewPrewarmService.captureObservedHosts(controller) } inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -426,7 +434,7 @@ internal class WebViewInAppViewHolder( override fun onPageFinished(url: String?) { mindboxLogD("onPageFinished: $url") currentWebViewOrigin = resolveOrigin(url) ?: currentWebViewOrigin - webViewController?.evaluateJavaScript(JS_CHECK_BRIDGE, ::checkEvaluateJavaScript) + startReadyCheck(url) } override fun onShouldOverrideUrlLoading(url: String?, isForMainFrame: Boolean?): Boolean { @@ -515,17 +523,70 @@ internal class WebViewInAppViewHolder( } } + /** + * Readiness probe for a freshly finished page. Module scripts can evaluate a beat after + * `onPageFinished` (slow device, cold cache), so a single early `false` must not close a + * healthy in-app — the checker polls before giving up. Every new `onPageFinished` (redirect, + * re-load) restarts the poll; teardown cancels it. The outgoing-message verification in + * [sendActionInternal] intentionally stays single-shot ([checkEvaluateJavaScript]) — that + * path talks to a page that already proved itself ready. + * + * Give-up records the failure but does NOT close: the init timer is the closing + * authority. The checker's ~1.2s budget can expire while an allowed same-origin + * navigation is still in flight or while a slow page is still booting its bridge — + * cases the timer would have accepted (the window stays invisible until `init` anyway). + */ + private fun startReadyCheck(url: String?) { + // A late onPageFinished can be queued on the main looper when the in-app closes; + // a checker started against the torn-down holder would only produce a spurious + // failure event. + if (webViewController == null) return + readyChecker?.cancel() + val checker = WebViewReadyChecker( + evaluate = { script, resultCallback -> + webViewController?.evaluateJavaScript(script, resultCallback) + ?: resultCallback(null) + }, + schedule = { delayMillis, action -> mainHandler.postDelayed(action, delayMillis) } + ) + readyChecker = checker + checker.run( + script = JS_CHECK_BRIDGE, + expectedResult = JS_RETURN, + onReady = { mindboxLogD("JS ready check passed for $url") }, + onGiveUp = { lastFailure -> + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "JS ready check gave up for $url: $lastFailure" + ) + } + ) + } + + /** + * Verifies an outgoing bridge call's JS result. Tracks the failure but does NOT close + * the in-app: whether one undelivered message is fatal is the caller's policy (a failed + * `back` action closes via its own onError, a failed motion event just stops monitoring) + * — force-closing here used to tear down a healthy show over a single transient miss. + * Page readiness has its own retrying probe ([startReadyCheck]). + */ internal fun checkEvaluateJavaScript(response: String?): Boolean { return when (response) { JS_RETURN -> true else -> { - inAppFailureTracker.sendFailureWithContext( - inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "evaluateJavaScript return unexpected response: $response", + // A miss during teardown (holder already closed, controller gone) is an + // expected race, not a presentation failure — don't feed it to telemetry. + if (webViewController != null) { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "evaluateJavaScript return unexpected response: $response", tags = wrapper.tags ) - inAppController.close() + } else { + mindboxLogW("evaluateJavaScript miss after teardown (ignored): $response") + } false } } @@ -605,6 +666,8 @@ internal class WebViewInAppViewHolder( private fun renderLayer(layer: Layer.WebViewLayer) { if (webViewController == null) { + // A real show takes priority: kill the prewarm so it can't compete for bandwidth. + webViewPrewarmService.onRealShowWillStart() val controller: WebViewController = createWebViewController(layer) webViewController = controller @@ -763,14 +826,18 @@ internal class WebViewInAppViewHolder( hapticFeedbackExecutor.cancel() motionService?.stopMonitoring() stopTimer() + readyChecker?.cancel() + readyChecker = null cancelPendingResponses("WebView In-App is closed") webViewController?.let { controller -> + // Detach first: a page event already queued on the main looper must not reach + // this torn-down holder (e.g. a late onPageFinished spawning a ready checker). + controller.setEventListener(null) val view: WebViewPlatformView = controller.view view.parent.safeAs()?.removeView(view) controller.destroy() } currentWebViewOrigin = null - webViewController?.destroy() webViewController = null currentMindboxView = null super.onClose() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt new file mode 100644 index 00000000..7ddf6230 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyChecker.kt @@ -0,0 +1,66 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +/** + * Polls the page for the JS bridge instead of deciding on a single onPageFinished-time probe. + * + * Module scripts can finish evaluating a beat after the page's load event on slow devices or + * with a cold cache, so one early `false` must not close a healthy in-app. The check re-runs + * on a short cadence and gives up only after the full budget; a new navigation (or teardown) + * cancels the previous checker outright. Mirrors the iOS SDK's WebViewReadyChecker. + * + * Pure logic: evaluation and scheduling are injected, so the class is JVM-testable. + */ +internal class WebViewReadyChecker( + private val evaluate: (script: String, resultCallback: (String?) -> Unit) -> Unit, + private val schedule: (delayMillis: Long, action: () -> Unit) -> Unit, +) { + + companion object { + const val MAX_ATTEMPTS: Int = 8 + const val RETRY_DELAY_MS: Long = 150L + } + + @Volatile + private var isCancelled = false + + fun run( + script: String, + expectedResult: String, + onReady: () -> Unit, + onGiveUp: (lastFailure: String) -> Unit + ) { + attempt(1, script, expectedResult, onReady, onGiveUp) + } + + /** + * Abandons the poll without calling either completion — the caller's new navigation + * (or teardown) owns readiness from here. + */ + fun cancel() { + isCancelled = true + } + + private fun attempt( + number: Int, + script: String, + expectedResult: String, + onReady: () -> Unit, + onGiveUp: (String) -> Unit + ) { + if (isCancelled) return + evaluate(script) { result -> + if (isCancelled) return@evaluate + if (result == expectedResult) { + onReady() + return@evaluate + } + if (number >= MAX_ATTEMPTS) { + onGiveUp("evaluateJavaScript returned unexpected response: $result") + return@evaluate + } + schedule(RETRY_DELAY_MS) { + attempt(number + 1, script, expectedResult, onReady, onGiveUp) + } + } + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt new file mode 100644 index 00000000..dcf7d82d --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt @@ -0,0 +1,57 @@ +package cloud.mindbox.mobile_sdk.inapp.data.managers + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cloud.mindbox.mobile_sdk.managers.SharedPreferencesManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class InAppWebViewLearnedHostsStoreTest { + + private val store = InAppWebViewLearnedHostsStore() + + @Before + fun setUp() { + SharedPreferencesManager.with(ApplicationProvider.getApplicationContext()) + SharedPreferencesManager.deleteAll() + } + + @Test + fun `hosts are empty by default`() { + assertTrue(store.hosts("Some.Endpoint").isEmpty()) + } + + @Test + fun `merge persists per endpoint and roundtrips`() { + store.merge("Endpoint.A", listOf("a.mindbox.ru", "b.mindbox.ru")) + store.merge("Endpoint.B", listOf("c.mindbox.ru")) + + assertEquals(listOf("a.mindbox.ru", "b.mindbox.ru"), store.hosts("Endpoint.A")) + assertEquals(listOf("c.mindbox.ru"), store.hosts("Endpoint.B")) + } + + @Test + fun `merge is newest first deduplicated and capped`() { + store.merge("Endpoint.A", (1..10).map { index -> "old$index.ru" }) + store.merge("Endpoint.A", listOf("new1.ru", "old1.ru", "new2.ru")) + + val hosts = store.hosts("Endpoint.A") + assertEquals(12, hosts.size) + assertEquals(listOf("new1.ru", "old1.ru", "new2.ru", "old1.ru").distinct().take(3), hosts.take(3)) + assertTrue(hosts.contains("old9.ru")) + } + + @Test + fun `merge ignores blank input`() { + store.merge("Endpoint.A", listOf(" ", "")) + store.merge("", listOf("host.ru")) + + assertTrue(store.hosts("Endpoint.A").isEmpty()) + assertTrue(store.hosts("").isEmpty()) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt index 4d73a19f..df4dcffc 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt @@ -98,7 +98,8 @@ internal class MobileConfigRepositoryImplTest { mobileConfigSettingsManager = mockk(relaxed = true), integerPositiveValidator = mockk(relaxed = true), inappSettingsManager = mockk(relaxed = true), - featureToggleManager = mockk(relaxed = true) + featureToggleManager = mockk(relaxed = true), + inAppWebViewPrewarmService = mockk(relaxed = true) ) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt new file mode 100644 index 00000000..0a2cc280 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt @@ -0,0 +1,373 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadBlankDto +import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl +import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator +import cloud.mindbox.mobile_sdk.inapp.domain.models.Form +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer +import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmEngine +import cloud.mindbox.mobile_sdk.managers.DbManager +import cloud.mindbox.mobile_sdk.managers.GatewayManager +import cloud.mindbox.mobile_sdk.models.Configuration +import cloud.mindbox.mobile_sdk.models.InAppStub +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import cloud.mindbox.mobile_sdk.utils.Constants +import cloud.mindbox.mobile_sdk.utils.RuntimeTypeAdapterFactory +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class InAppWebViewPrewarmServiceImplTest { + + private val configuration = Configuration( + previousInstallationId = "", + previousDeviceUUID = "", + endpointId = "Test.Endpoint", + domain = "api.mindbox.ru", + packageName = "test.package", + versionName = "1.0", + versionCode = "1", + subscribeCustomerIfCreated = false, + shouldCreateCustomer = false + ) + + private val webViewInApp = InAppStub.getInApp().copy( + form = Form( + variants = listOf( + InAppType.WebView( + inAppId = "id", + type = "webview", + layers = listOf( + Layer.WebViewLayer( + baseUrl = "https://inapp.local/popup", + contentUrl = "https://mobile-static.mindbox.ru/content/index.html", + type = "webview", + params = emptyMap() + ) + ) + ) + ) + ) + ) + + private lateinit var engine: InAppWebViewPrewarmEngine + private lateinit var gatewayManager: GatewayManager + private lateinit var service: InAppWebViewPrewarmServiceImpl + + @Before + fun setUp() { + mockkObject(Mindbox) + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher()) + mockkObject(DbManager) + every { DbManager.listenConfigurations() } returns flowOf(configuration) + mockkObject(MindboxPreferences) + every { MindboxPreferences.deviceUuid } returns "test-device-uuid" + engine = mockk(relaxed = true) + gatewayManager = mockk(relaxed = true) + coEvery { gatewayManager.fetchWebViewContent(any()) } returns "" + service = InAppWebViewPrewarmServiceImpl( + engine = engine, + mobileConfigSerializationManager = MobileConfigSerializationManagerImpl(gson = configGson()), + gatewayManager = lazyOf(gatewayManager), + inAppValidator = mockk(relaxed = true) { + every { validateInAppVersion(any()) } returns true + }, + webViewLayerValidator = WebViewLayerValidator(), + learnedHostsStore = mockk(relaxed = true) { + every { hosts(any()) } returns listOf("learned-cdn.mindbox.ru") + } + ) + } + + @After + fun tearDown() { + unmockkObject(Mindbox) + unmockkObject(DbManager) + unmockkObject(MindboxPreferences) + } + + private fun configWith(vararg inApps: cloud.mindbox.mobile_sdk.inapp.domain.models.InApp) = InAppConfig( + inApps = inApps.toList(), + monitoring = emptyList(), + operations = emptyMap(), + abtests = emptyList() + ) + + @Test + fun `prewarmResources loads preconnect and content page once`() { + service.prewarmResources(configWith(webViewInApp)) + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { + engine.loadPreconnectPage( + html = match { html -> + html.contains("https://mobile-static.mindbox.ru") && + html.contains("https://api.mindbox.ru") && + html.contains("https://learned-cdn.mindbox.ru") + }, + baseUrl = "https://inapp.local/popup", + userAgentSuffix = any() + ) + } + coVerify(exactly = 1) { gatewayManager.fetchWebViewContent("https://mobile-static.mindbox.ru/content/index.html") } + verify(exactly = 1) { + engine.loadContentPage( + html = "", + // Official prewarm contract: the content page's document URL carries the params. + baseUrl = "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + userAgentSuffix = any() + ) + } + } + + @Test + fun `prewarm webview is released by network idle well before the hard cap`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // Page reports a stable resource list whose last download finished long ago. + every { engine.evaluateJavaScript(any(), any()) } answers { + secondArg<(String?) -> Unit>().invoke("\"6:5000\"") + } + + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.release() } + + // Two stable polls after the baseline one -> idle at the third second, not at 30s. + advanceTimeBy(3_100) + verify(exactly = 1) { engine.release() } + verify(exactly = 0) { engine.abort() } + } + + @Test + fun `garbage probes are charged the probe timeout and drain the cap budget early`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + every { engine.evaluateJavaScript(any(), any()) } answers { + secondArg<(String?) -> Unit>().invoke("\"\"") + } + + service.prewarmResources(configWith(webViewInApp)) + + // Each 1s poll yields a garbage (null) probe charged the 2s probe timeout on top of + // its own second, so the 30-unit budget drains after 10 polls — the cap is a + // wall-clock bound even when the page never answers usefully. + advanceTimeBy(9_500) + verify(exactly = 0) { engine.release() } + advanceTimeBy(1_000) + verify(exactly = 1) { engine.release() } + } + + @Test + fun `a real show during the settle poll stops the poller at the next tick`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // The page keeps downloading (entry count grows every poll), so the poll never + // goes idle by itself. + var count = 0 + every { engine.evaluateJavaScript(any(), any()) } answers { + count++ + secondArg<(String?) -> Unit>().invoke("\"$count:100\"") + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(2_500) + service.onRealShowWillStart() + advanceTimeBy(60_000) + + // The poller stops (job cancelled; the per-tick hasAborted guard is the backstop): + // the terminal abort stays the only teardown, no second release lands mid-show. + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.release() } + } + + @Test + fun `terminal preempt during the content fetch prevents both the content load and the settle poll`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // The real show starts while the prewarm is suspended in the content fetch. + coEvery { gatewayManager.fetchWebViewContent(any()) } coAnswers { + service.onRealShowWillStart() + "" + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(31_000) + + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + // Only the terminal abort released the engine; no zombie poll produced a second one. + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.release() } + } + + @Test + fun `a no-layers config arriving mid-fetch stops the content load from resurrecting a webview`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + coEvery { gatewayManager.fetchWebViewContent(any()) } coAnswers { + service.prewarmResources(configWith(InAppStub.getInApp())) + "" + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(31_000) + + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + // The resumed prewarm must RELEASE, not bare-return: its preconnect load already + // created a WebView, and this path schedules no settle poll to free it later. + // (First release comes from the no-layers branch itself, second from the resume.) + verify(exactly = 2) { engine.release() } + } + + @Test + fun `a no-layers config arriving before the preconnect load keeps the webview from being created`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + // The fresh no-layers config lands while the prewarm is suspended reading the + // saved configuration — before it has touched the engine at all. + every { DbManager.listenConfigurations() } answers { + service.prewarmResources(configWith(InAppStub.getInApp())) + flowOf(configuration) + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(31_000) + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `an attempt without a saved configuration does not consume the one-shot`() { + every { DbManager.listenConfigurations() } returnsMany listOf( + kotlinx.coroutines.flow.emptyFlow(), + flowOf(configuration) + ) + + // First attempt: configuration read fails -> skipped, one-shot must survive. + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + + // Second attempt with a readable configuration warms normally. + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + + @Test + fun `prewarmResources releases warm webview when config has no webview inapps`() { + service.prewarmResources(configWith(InAppStub.getInApp())) + + verify(exactly = 1) { engine.release() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `real show preempts prewarm terminally and blocks later attempts`() { + service.onRealShowWillStart() + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `prewarmOnInit warms from cached config without validation pipeline`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson() + + service.prewarmOnInit() + + verify(exactly = 1) { engine.loadPreconnectPage(any(), "https://inapp.local/popup", any()) } + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + + @Test + fun `prewarmOnInit does nothing without cached config`() { + every { MindboxPreferences.inAppConfig } returns "" + + service.prewarmOnInit() + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + } + + /** Same `${'$'}type` adapters the production gson registers for the form payload path. */ + private fun configGson(): Gson = GsonBuilder() + .registerTypeAdapterFactory( + RuntimeTypeAdapterFactory + .of(PayloadBlankDto::class.java, Constants.TYPE_JSON_NAME, true) + .registerSubtype(PayloadBlankDto.ModalWindowBlankDto::class.java, PayloadDto.ModalWindowDto.MODAL_JSON_NAME) + .registerSubtype(PayloadBlankDto.SnackBarBlankDto::class.java, PayloadDto.SnackbarDto.SNACKBAR_JSON_NAME) + ) + .registerTypeAdapterFactory( + RuntimeTypeAdapterFactory + .of(BackgroundDto.LayerDto::class.java, Constants.TYPE_JSON_NAME, true) + .registerSubtype( + BackgroundDto.LayerDto.ImageLayerDto::class.java, + BackgroundDto.LayerDto.ImageLayerDto.IMAGE_TYPE_JSON_NAME + ) + .registerSubtype( + BackgroundDto.LayerDto.WebViewLayerDto::class.java, + BackgroundDto.LayerDto.WebViewLayerDto.WEBVIEW_TYPE_JSON_NAME + ) + ) + .create() + + private fun cachedConfigJson(): String = """ + { + "inapps": [ + { + "id": "cached-webview", + "sdkVersion": { "min": 1, "max": null }, + "targeting": { "${'$'}type": "true" }, + "form": { + "variants": [ + { + "${'$'}type": "modal", + "content": { + "background": { + "layers": [ + { + "${'$'}type": "webview", + "baseUrl": "https://inapp.local/popup", + "contentUrl": "https://mobile-static.mindbox.ru/content/index.html", + "params": { "formId": "159510" } + } + ] + }, + "elements": [] + } + } + ] + } + } + ] + } + """.trimIndent() +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt new file mode 100644 index 00000000..a7ff1f95 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewReadyCheckerTest.kt @@ -0,0 +1,106 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class WebViewReadyCheckerTest { + + /** + * Drives the checker synchronously: `evaluate` answers from a scripted sequence + * (empty -> "false") and scheduled retries run immediately unless held for the + * cancellation test. + */ + private class Harness( + private val answers: MutableList, + private val runScheduledImmediately: Boolean = true + ) { + var evaluateCount = 0 + private set + val scheduledDelays = mutableListOf() + val pendingWork = mutableListOf<() -> Unit>() + + fun makeChecker(): WebViewReadyChecker = WebViewReadyChecker( + evaluate = { _, resultCallback -> + evaluateCount++ + resultCallback(if (answers.isEmpty()) "false" else answers.removeAt(0)) + }, + schedule = { delayMillis, action -> + scheduledDelays.add(delayMillis) + if (runScheduledImmediately) action() else pendingWork.add(action) + } + ) + } + + @Test + fun `an immediately ready page passes on the first attempt`() { + val harness = Harness(mutableListOf("true")) + var readyCalls = 0 + + harness.makeChecker().run( + script = "check", + expectedResult = "true", + onReady = { readyCalls++ }, + onGiveUp = { throw AssertionError("must not give up") } + ) + + assertEquals(1, readyCalls) + assertEquals(1, harness.evaluateCount) + assertTrue(harness.scheduledDelays.isEmpty()) + } + + @Test + fun `a module evaluating after onPageFinished passes on a retry instead of closing the show`() { + val harness = Harness(mutableListOf("false", null, "true")) + var readyCalls = 0 + + harness.makeChecker().run( + script = "check", + expectedResult = "true", + onReady = { readyCalls++ }, + onGiveUp = { throw AssertionError("must not give up") } + ) + + assertEquals(1, readyCalls) + assertEquals(3, harness.evaluateCount) + assertEquals( + listOf(WebViewReadyChecker.RETRY_DELAY_MS, WebViewReadyChecker.RETRY_DELAY_MS), + harness.scheduledDelays + ) + } + + @Test + fun `a page that never boots gives up only after the full retry budget`() { + val harness = Harness(mutableListOf()) + val giveUpReasons = mutableListOf() + + harness.makeChecker().run( + script = "check", + expectedResult = "true", + onReady = { throw AssertionError("must not become ready") }, + onGiveUp = { reason -> giveUpReasons.add(reason) } + ) + + assertEquals(1, giveUpReasons.size) + assertEquals(WebViewReadyChecker.MAX_ATTEMPTS, harness.evaluateCount) + } + + @Test + fun `cancel abandons the poll without ever resolving`() { + val harness = Harness(mutableListOf(), runScheduledImmediately = false) + val checker = harness.makeChecker() + checker.run( + script = "check", + expectedResult = "true", + onReady = { throw AssertionError("must not become ready") }, + onGiveUp = { throw AssertionError("must not give up") } + ) + assertEquals(1, harness.evaluateCount) + + checker.cancel() + harness.pendingWork.forEach { work -> work() } + + // The cancelled checker neither evaluates again nor resolves. + assertEquals(1, harness.evaluateCount) + } +} From 6ad4445059ed0863a5258f360a2465011ffd9620 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 10 Jul 2026 12:28:55 +0300 Subject: [PATCH 29/37] MOBILE-269: Remove profiling. Rename InAppWebViewPrewService. --- .gitignore | 1 + kmp-common-sdk | 2 +- .../main/java/cloud/mindbox/mobile_sdk/Mindbox.kt | 2 +- .../mindbox/mobile_sdk/di/modules/DataModule.kt | 10 +++++----- .../mindbox/mobile_sdk/di/modules/MindboxModule.kt | 2 +- .../repositories/MobileConfigRepositoryImpl.kt | 6 +++--- ...wPrewarmService.kt => InAppWebViewPrewarmer.kt} | 14 +++----------- .../presentation/view/WebViewInappViewHolder.kt | 8 ++++---- .../managers/InAppWebViewLearnedHostsStoreTest.kt | 12 ++++++++++++ .../repositories/MobileConfigRepositoryImplTest.kt | 2 +- ...mplTest.kt => InAppWebViewPrewarmerImplTest.kt} | 6 +++--- 11 files changed, 35 insertions(+), 30 deletions(-) rename sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/{InAppWebViewPrewarmService.kt => InAppWebViewPrewarmer.kt} (97%) rename sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/{InAppWebViewPrewarmServiceImplTest.kt => InAppWebViewPrewarmerImplTest.kt} (98%) diff --git a/.gitignore b/.gitignore index 4ee9d130..3cfad2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -357,3 +357,4 @@ hs_err_pid* # End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,macos,linux,intellij /keys +/example/example.properties diff --git a/kmp-common-sdk b/kmp-common-sdk index 19df8a93..1ab60a84 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 19df8a9382530d74d1d14e52c580ebdc3c08f92f +Subproject commit 1ab60a841b5990ee4cc32b7d25acb369249b6458 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index b163c673..5de8ae0c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -564,7 +564,7 @@ public object Mindbox : MindboxLog { initComponents(context.applicationContext) // Prewarm stage 1: head start for webview in-apps from the cached config // (mirrors the iOS SDK's init-time prewarm; a real show always preempts it). - MindboxDI.appModule.inAppWebViewPrewarmService.prewarmOnInit() + MindboxDI.appModule.inAppWebViewPrewarmer.prewarmOnInit() pushConverters = selectPushServiceHandler(pushServices) logI( "init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 92f5225a..3d87556c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -26,8 +26,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSer import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmServiceImpl +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage @@ -149,8 +149,8 @@ internal fun DataModule( ) } - override val inAppWebViewPrewarmService: InAppWebViewPrewarmService by lazy { - InAppWebViewPrewarmServiceImpl( + override val inAppWebViewPrewarmer: InAppWebViewPrewarmer by lazy { + InAppWebViewPrewarmerImpl( engine = InAppWebViewPrewarmEngine(appContext) { message -> mindboxLogI("[WebView] Prewarm: $message") }, @@ -184,7 +184,7 @@ internal fun DataModule( integerPositiveValidator = integerPositiveValidator, inappSettingsManager = inappSettingsManager, featureToggleManager = featureToggleManager, - inAppWebViewPrewarmService = inAppWebViewPrewarmService + inAppWebViewPrewarmer = inAppWebViewPrewarmer ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index f03e5746..d1ae5495 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -64,7 +64,7 @@ internal interface DataModule : MindboxModule { val sessionStorageManager: SessionStorageManager val mobileConfigRepository: MobileConfigRepository val mobileConfigSerializationManager: MobileConfigSerializationManager - val inAppWebViewPrewarmService: InAppWebViewPrewarmService + val inAppWebViewPrewarmer: InAppWebViewPrewarmer val inAppGeoRepository: InAppGeoRepository val inAppRepository: InAppRepository val callbackRepository: CallbackRepository diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 8c1a13ae..509205ef 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -13,7 +13,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTtlData -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI @@ -53,7 +53,7 @@ internal class MobileConfigRepositoryImpl( private val integerPositiveValidator: IntegerPositiveValidator, private val inappSettingsManager: InappSettingsManager, private val featureToggleManager: FeatureToggleManager, - private val inAppWebViewPrewarmService: InAppWebViewPrewarmService + private val inAppWebViewPrewarmer: InAppWebViewPrewarmer ) : MobileConfigRepository { private val mutex = Mutex() @@ -109,7 +109,7 @@ internal class MobileConfigRepositoryImpl( configState.value = updatedInAppConfig // Prewarm stage 2: warm what the config's webview in-apps will need // (or release the warm instance when the config proves there are none). - inAppWebViewPrewarmService.prewarmResources(updatedInAppConfig) + inAppWebViewPrewarmer.prewarmResources(updatedInAppConfig) mindboxLogI(message = "Providing config: $updatedInAppConfig") } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt similarity index 97% rename from sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt rename to sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt index e4ea0e39..41a2788d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmService.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt @@ -14,7 +14,6 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmEngine import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmLayer import cloud.mindbox.mobile_sdk.inapp.webview.InAppWebViewPrewarmPlanner -import cloud.mindbox.mobile_sdk.inapp.webview.MindboxWebViewLab import cloud.mindbox.mobile_sdk.inapp.webview.WebViewController import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.logger.mindboxLogW @@ -52,7 +51,7 @@ import java.util.concurrent.atomic.AtomicReference * handed over. Unlike iOS there is no instance reuse — Android shares the renderer * process, so a warm instance buys nothing (measured). */ -internal interface InAppWebViewPrewarmService { +internal interface InAppWebViewPrewarmer { /** Prewarm stage 1: head start from the cached config. Call once at SDK init. */ fun prewarmOnInit() @@ -75,14 +74,14 @@ internal interface InAppWebViewPrewarmService { } @OptIn(InternalMindboxApi::class) -internal class InAppWebViewPrewarmServiceImpl( +internal class InAppWebViewPrewarmerImpl( private val engine: InAppWebViewPrewarmEngine, private val mobileConfigSerializationManager: MobileConfigSerializationManager, private val gatewayManager: Lazy, private val inAppValidator: InAppValidator, private val webViewLayerValidator: WebViewLayerValidator, private val learnedHostsStore: InAppWebViewLearnedHostsStore -) : InAppWebViewPrewarmService { +) : InAppWebViewPrewarmer { companion object { // Hard cap on how long the hidden WebView may live after the content page was @@ -116,7 +115,6 @@ internal class InAppWebViewPrewarmServiceImpl( private val settleJob = AtomicReference(null) override fun prewarmOnInit() { - if (!MindboxWebViewLab.PREWARM_ENABLED) return // MEASUREMENT (throwaway) gate Mindbox.mindboxScope.launch { loggingRunCatchingSuspending { val cachedConfig = MindboxPreferences.inAppConfig @@ -130,7 +128,6 @@ internal class InAppWebViewPrewarmServiceImpl( } override fun prewarmResources(config: InAppConfig) { - if (!MindboxWebViewLab.PREWARM_ENABLED) return // MEASUREMENT (throwaway) gate val layers = config.inApps .flatMap { inApp -> inApp.form.variants } .filterIsInstance() @@ -207,11 +204,6 @@ internal class InAppWebViewPrewarmServiceImpl( mindboxLogI("[WebView] Prewarm: preconnect to ${plan.preconnectOrigins.joinToString(",")} under ${plan.baseUrl}") engine.loadPreconnectPage(plan.preconnectHtml, plan.baseUrl, userAgentSuffix) - if (MindboxWebViewLab.PREWARM_PRECONNECT_ONLY) { // MEASUREMENT (throwaway) gate - scheduleSettleRelease() - return - } - val html = runCatching { gatewayManager.value.fetchWebViewContent(plan.contentUrl) } .getOrElse { error -> mindboxLogW("[WebView] Prewarm: content page fetch failed: $error") diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 5a0e42fb..0e368bbe 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -24,7 +24,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmService +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import androidx.lifecycle.ProcessLifecycleOwner @@ -94,7 +94,7 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val timeProvider: TimeProvider by mindboxInject { timeProvider } - private val webViewPrewarmService: InAppWebViewPrewarmService by mindboxInject { inAppWebViewPrewarmService } + private val webViewPrewarmer: InAppWebViewPrewarmer by mindboxInject { inAppWebViewPrewarmer } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -324,7 +324,7 @@ internal class WebViewInAppViewHolder( private fun handleCloseAction(message: BridgeMessage): String { motionService?.stopMonitoring() // Remember which https hosts this show actually used — feeds the next launch's preconnect. - webViewController?.let { controller -> webViewPrewarmService.captureObservedHosts(controller) } + webViewController?.let { controller -> webViewPrewarmer.captureObservedHosts(controller) } inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -667,7 +667,7 @@ internal class WebViewInAppViewHolder( private fun renderLayer(layer: Layer.WebViewLayer) { if (webViewController == null) { // A real show takes priority: kill the prewarm so it can't compete for bandwidth. - webViewPrewarmService.onRealShowWillStart() + webViewPrewarmer.onRealShowWillStart() val controller: WebViewController = createWebViewController(layer) webViewController = controller diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt index dcf7d82d..3030e87a 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppWebViewLearnedHostsStoreTest.kt @@ -46,6 +46,18 @@ internal class InAppWebViewLearnedHostsStoreTest { assertTrue(hosts.contains("old9.ru")) } + @Test + fun `merge drops oldest hosts beyond MAX_HOSTS`() { + store.merge("Endpoint.A", (1..12).map { index -> "old$index.ru" }) + store.merge("Endpoint.A", (1..5).map { index -> "new$index.ru" }) + + val hosts = store.hosts("Endpoint.A") + assertEquals(12, hosts.size) + assertEquals((1..5).map { index -> "new$index.ru" }, hosts.take(5)) + assertEquals((1..7).map { index -> "old$index.ru" }, hosts.drop(5)) + assertTrue((8..12).none { index -> hosts.contains("old$index.ru") }) + } + @Test fun `merge ignores blank input`() { store.merge("Endpoint.A", listOf(" ", "")) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt index df4dcffc..76436edc 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt @@ -99,7 +99,7 @@ internal class MobileConfigRepositoryImplTest { integerPositiveValidator = mockk(relaxed = true), inappSettingsManager = mockk(relaxed = true), featureToggleManager = mockk(relaxed = true), - inAppWebViewPrewarmService = mockk(relaxed = true) + inAppWebViewPrewarmer = mockk(relaxed = true) ) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt similarity index 98% rename from sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt rename to sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt index 0a2cc280..5122d66d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmServiceImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt @@ -38,7 +38,7 @@ import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -internal class InAppWebViewPrewarmServiceImplTest { +internal class InAppWebViewPrewarmerImplTest { private val configuration = Configuration( previousInstallationId = "", @@ -73,7 +73,7 @@ internal class InAppWebViewPrewarmServiceImplTest { private lateinit var engine: InAppWebViewPrewarmEngine private lateinit var gatewayManager: GatewayManager - private lateinit var service: InAppWebViewPrewarmServiceImpl + private lateinit var service: InAppWebViewPrewarmerImpl @Before fun setUp() { @@ -86,7 +86,7 @@ internal class InAppWebViewPrewarmServiceImplTest { engine = mockk(relaxed = true) gatewayManager = mockk(relaxed = true) coEvery { gatewayManager.fetchWebViewContent(any()) } returns "" - service = InAppWebViewPrewarmServiceImpl( + service = InAppWebViewPrewarmerImpl( engine = engine, mobileConfigSerializationManager = MobileConfigSerializationManagerImpl(gson = configGson()), gatewayManager = lazyOf(gatewayManager), From 9f0b6b9e462230c6ccc8bb1df133a4a1102dfe3c Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 10 Jul 2026 18:40:40 +0300 Subject: [PATCH 30/37] MOBILE-269: Add feature toggles for webview cache --- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 2 +- .../mobile_sdk/di/modules/DataModule.kt | 21 +++--- .../mobile_sdk/di/modules/MindboxModule.kt | 2 +- .../data/managers/FeatureToggleManagerImpl.kt | 7 +- .../MobileConfigRepositoryImpl.kt | 6 +- ...armer.kt => InAppWebViewPrewarmManager.kt} | 42 +++++++++-- .../view/WebViewInappViewHolder.kt | 17 +++-- .../MobileConfigRepositoryImplTest.kt | 2 +- ... => InAppWebViewPrewarmManagerImplTest.kt} | 75 +++++++++++++++++-- 9 files changed, 138 insertions(+), 36 deletions(-) rename sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/{InAppWebViewPrewarmer.kt => InAppWebViewPrewarmManager.kt} (91%) rename sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/{InAppWebViewPrewarmerImplTest.kt => InAppWebViewPrewarmManagerImplTest.kt} (83%) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 5de8ae0c..b147c270 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -564,7 +564,7 @@ public object Mindbox : MindboxLog { initComponents(context.applicationContext) // Prewarm stage 1: head start for webview in-apps from the cached config // (mirrors the iOS SDK's init-time prewarm; a real show always preempts it). - MindboxDI.appModule.inAppWebViewPrewarmer.prewarmOnInit() + MindboxDI.appModule.inAppWebViewPrewarmManager.prewarmOnInit() pushConverters = selectPushServiceHandler(pushServices) logI( "init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 3d87556c..734e0aad 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -26,8 +26,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSer import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmerImpl +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.view.BridgeMessage @@ -149,11 +149,13 @@ internal fun DataModule( ) } - override val inAppWebViewPrewarmer: InAppWebViewPrewarmer by lazy { - InAppWebViewPrewarmerImpl( - engine = InAppWebViewPrewarmEngine(appContext) { message -> - mindboxLogI("[WebView] Prewarm: $message") - }, + override val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager by lazy { + InAppWebViewPrewarmManagerImpl( + engine = InAppWebViewPrewarmEngine( + appContext = appContext, + log = { message -> mindboxLogI("[WebView] Prewarm: $message") }, + isCacheEnabled = { featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE) } + ), mobileConfigSerializationManager = mobileConfigSerializationManager, // Lazy on purpose: this service is resolved on the main thread during SDK init // (prewarm stage 1), and constructing GatewayManager spins up Volley (thread @@ -161,7 +163,8 @@ internal fun DataModule( gatewayManager = lazy { gatewayManager }, inAppValidator = inAppValidator, webViewLayerValidator = webViewLayerValidator, - learnedHostsStore = InAppWebViewLearnedHostsStore() + learnedHostsStore = InAppWebViewLearnedHostsStore(), + featureToggleManager = featureToggleManager ) } @@ -184,7 +187,7 @@ internal fun DataModule( integerPositiveValidator = integerPositiveValidator, inappSettingsManager = inappSettingsManager, featureToggleManager = featureToggleManager, - inAppWebViewPrewarmer = inAppWebViewPrewarmer + inAppWebViewPrewarmManager = inAppWebViewPrewarmManager ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index d1ae5495..ddfc9f23 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -64,7 +64,7 @@ internal interface DataModule : MindboxModule { val sessionStorageManager: SessionStorageManager val mobileConfigRepository: MobileConfigRepository val mobileConfigSerializationManager: MobileConfigSerializationManager - val inAppWebViewPrewarmer: InAppWebViewPrewarmer + val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager val inAppGeoRepository: InAppGeoRepository val inAppRepository: InAppRepository val callbackRepository: CallbackRepository diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt index c580c6ad..2dc1a801 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt @@ -6,6 +6,11 @@ import java.util.concurrent.ConcurrentHashMap internal const val SEND_INAPP_SHOW_ERROR_FEATURE = "MobileSdkShouldSendInAppShowError" internal const val SEND_INAPP_TAGS_FEATURE = "MobileSdkShouldSendInAppTags" +internal const val PREWARM_INAPP_WEBVIEW_FEATURE = "MobileSdkShouldPrewarmInAppWebView" +internal const val CACHE_INAPP_WEBVIEW_FEATURE = "MobileSdkShouldCacheInAppWebView" + +/** Every toggle is a kill switch: an absent/unknown key defaults to enabled. */ +internal const val FEATURE_TOGGLE_DEFAULT: Boolean = true internal class FeatureToggleManagerImpl : FeatureToggleManager { @@ -21,6 +26,6 @@ internal class FeatureToggleManagerImpl : FeatureToggleManager { } override fun isEnabled(key: String): Boolean { - return toggles[key] ?: true + return toggles[key] ?: FEATURE_TOGGLE_DEFAULT } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt index 509205ef..770335e6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImpl.kt @@ -13,7 +13,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTtlData -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.logger.mindboxLogD import cloud.mindbox.mobile_sdk.logger.mindboxLogE import cloud.mindbox.mobile_sdk.logger.mindboxLogI @@ -53,7 +53,7 @@ internal class MobileConfigRepositoryImpl( private val integerPositiveValidator: IntegerPositiveValidator, private val inappSettingsManager: InappSettingsManager, private val featureToggleManager: FeatureToggleManager, - private val inAppWebViewPrewarmer: InAppWebViewPrewarmer + private val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager ) : MobileConfigRepository { private val mutex = Mutex() @@ -109,7 +109,7 @@ internal class MobileConfigRepositoryImpl( configState.value = updatedInAppConfig // Prewarm stage 2: warm what the config's webview in-apps will need // (or release the warm instance when the config proves there are none). - inAppWebViewPrewarmer.prewarmResources(updatedInAppConfig) + inAppWebViewPrewarmManager.prewarmResources(updatedInAppConfig) mindboxLogI(message = "Providing config: $updatedInAppConfig") } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt similarity index 91% rename from sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt rename to sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt index 41a2788d..df7a2eda 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt @@ -4,8 +4,11 @@ import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.FEATURE_TOGGLE_DEFAULT import cloud.mindbox.mobile_sdk.inapp.data.managers.InAppWebViewLearnedHostsStore +import cloud.mindbox.mobile_sdk.inapp.data.managers.PREWARM_INAPP_WEBVIEW_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig @@ -21,6 +24,7 @@ import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.models.getShortUserAgent +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponseBlank import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import cloud.mindbox.mobile_sdk.utils.loggingRunCatchingSuspending @@ -51,7 +55,7 @@ import java.util.concurrent.atomic.AtomicReference * handed over. Unlike iOS there is no instance reuse — Android shares the renderer * process, so a warm instance buys nothing (measured). */ -internal interface InAppWebViewPrewarmer { +internal interface InAppWebViewPrewarmManager { /** Prewarm stage 1: head start from the cached config. Call once at SDK init. */ fun prewarmOnInit() @@ -74,14 +78,15 @@ internal interface InAppWebViewPrewarmer { } @OptIn(InternalMindboxApi::class) -internal class InAppWebViewPrewarmerImpl( +internal class InAppWebViewPrewarmManagerImpl( private val engine: InAppWebViewPrewarmEngine, private val mobileConfigSerializationManager: MobileConfigSerializationManager, private val gatewayManager: Lazy, private val inAppValidator: InAppValidator, private val webViewLayerValidator: WebViewLayerValidator, - private val learnedHostsStore: InAppWebViewLearnedHostsStore -) : InAppWebViewPrewarmer { + private val learnedHostsStore: InAppWebViewLearnedHostsStore, + private val featureToggleManager: FeatureToggleManager +) : InAppWebViewPrewarmManager { companion object { // Hard cap on how long the hidden WebView may live after the content page was @@ -128,6 +133,13 @@ internal class InAppWebViewPrewarmerImpl( } override fun prewarmResources(config: InAppConfig) { + // A fresh config that turns the toggle off also kills a stage-1 instance started + // under the previous launch's config. + if (!featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE)) { + mindboxLogI("[WebView] Prewarm: feature toggle is off, releasing warm WebView") + releaseWarmWebView() + return + } val layers = config.inApps .flatMap { inApp -> inApp.form.variants } .filterIsInstance() @@ -136,9 +148,7 @@ internal class InAppWebViewPrewarmerImpl( .map { layer -> InAppWebViewPrewarmLayer(baseUrl = layer.baseUrl, contentUrl = layer.contentUrl) } if (layers.isEmpty()) { mindboxLogI("[WebView] Prewarm: config has no webview in-apps, releasing warm WebView") - latestConfigHasNoLayers.set(true) - settleJob.getAndSet(null)?.cancel() - engine.release() + releaseWarmWebView() return } latestConfigHasNoLayers.set(false) @@ -149,6 +159,12 @@ internal class InAppWebViewPrewarmerImpl( } } + private fun releaseWarmWebView() { + latestConfigHasNoLayers.set(true) + settleJob.getAndSet(null)?.cancel() + engine.release() + } + override fun onRealShowWillStart() { hasAborted.set(true) settleJob.getAndSet(null)?.cancel() @@ -311,10 +327,17 @@ internal class InAppWebViewPrewarmerImpl( private suspend fun currentConfiguration(): Configuration? = runCatching { DbManager.listenConfigurations().first() }.getOrNull() - /** Light parse of the cached config: only webview layer urls, no targeting checks. */ + /** + * Light parse of the cached config: only webview layer urls, no targeting checks. + * + * The toggle is read from THIS cached config, not [featureToggleManager]: stage 1 races + * the fresh config download, so the manager may still hold last launch's (or no) state + * when this runs. + */ private fun webViewLayers(configString: String): List { val configBlank = mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) ?: return emptyList() + if (!isPrewarmEnabled(configBlank)) return emptyList() return configBlank.inApps.orEmpty() // Same version gate as the real pipeline: in-apps for other SDK versions may // carry form formats this version cannot even deserialize. @@ -330,6 +353,9 @@ internal class InAppWebViewPrewarmerImpl( .map { layerDto -> InAppWebViewPrewarmLayer(baseUrl = layerDto.baseUrl, contentUrl = layerDto.contentUrl) } } + private fun isPrewarmEnabled(configBlank: InAppConfigResponseBlank): Boolean = + configBlank.settings?.featureToggles?.toggles?.get(PREWARM_INAPP_WEBVIEW_FEATURE) ?: FEATURE_TOGGLE_DEFAULT + /** * `evaluateJavascript` returns the JS value JSON-encoded; the probe returns a string * containing a JSON array, so unwrap the outer string and then parse the array. diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 0e368bbe..194b8f34 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -14,17 +14,19 @@ import cloud.mindbox.mobile_sdk.* import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto +import cloud.mindbox.mobile_sdk.inapp.data.managers.CACHE_INAPP_WEBVIEW_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator import cloud.mindbox.mobile_sdk.inapp.data.validators.HapticRequestValidator import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback -import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmer +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView import androidx.lifecycle.ProcessLifecycleOwner @@ -94,7 +96,8 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val timeProvider: TimeProvider by mindboxInject { timeProvider } - private val webViewPrewarmer: InAppWebViewPrewarmer by mindboxInject { inAppWebViewPrewarmer } + private val webViewPrewarmManager: InAppWebViewPrewarmManager by mindboxInject { inAppWebViewPrewarmManager } + private val featureToggleManager: FeatureToggleManager by mindboxInject { featureToggleManager } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -324,7 +327,7 @@ internal class WebViewInAppViewHolder( private fun handleCloseAction(message: BridgeMessage): String { motionService?.stopMonitoring() // Remember which https hosts this show actually used — feeds the next launch's preconnect. - webViewController?.let { controller -> webViewPrewarmer.captureObservedHosts(controller) } + webViewController?.let { controller -> webViewPrewarmManager.captureObservedHosts(controller) } inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -424,7 +427,11 @@ internal class WebViewInAppViewHolder( private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController { mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}") - val controller: WebViewController = WebViewController.create(currentDialog.context, BuildConfig.DEBUG) + val controller: WebViewController = WebViewController.create( + context = currentDialog.context, + isDebugEnabled = BuildConfig.DEBUG, + isCacheEnabled = featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE) + ) val view: WebViewPlatformView = controller.view view.layoutParams = RelativeLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -667,7 +674,7 @@ internal class WebViewInAppViewHolder( private fun renderLayer(layer: Layer.WebViewLayer) { if (webViewController == null) { // A real show takes priority: kill the prewarm so it can't compete for bandwidth. - webViewPrewarmer.onRealShowWillStart() + webViewPrewarmManager.onRealShowWillStart() val controller: WebViewController = createWebViewController(layer) webViewController = controller diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt index 76436edc..d378d8f7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/MobileConfigRepositoryImplTest.kt @@ -99,7 +99,7 @@ internal class MobileConfigRepositoryImplTest { integerPositiveValidator = mockk(relaxed = true), inappSettingsManager = mockk(relaxed = true), featureToggleManager = mockk(relaxed = true), - inAppWebViewPrewarmer = mockk(relaxed = true) + inAppWebViewPrewarmManager = mockk(relaxed = true) ) } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt similarity index 83% rename from sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt rename to sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt index 5122d66d..5ff07197 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt @@ -5,7 +5,9 @@ import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadBlankDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl +import cloud.mindbox.mobile_sdk.inapp.data.managers.PREWARM_INAPP_WEBVIEW_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.models.Form import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType @@ -38,7 +40,7 @@ import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -internal class InAppWebViewPrewarmerImplTest { +internal class InAppWebViewPrewarmManagerImplTest { private val configuration = Configuration( previousInstallationId = "", @@ -73,7 +75,8 @@ internal class InAppWebViewPrewarmerImplTest { private lateinit var engine: InAppWebViewPrewarmEngine private lateinit var gatewayManager: GatewayManager - private lateinit var service: InAppWebViewPrewarmerImpl + private lateinit var featureToggleManager: FeatureToggleManager + private lateinit var service: InAppWebViewPrewarmManagerImpl @Before fun setUp() { @@ -86,7 +89,10 @@ internal class InAppWebViewPrewarmerImplTest { engine = mockk(relaxed = true) gatewayManager = mockk(relaxed = true) coEvery { gatewayManager.fetchWebViewContent(any()) } returns "" - service = InAppWebViewPrewarmerImpl( + featureToggleManager = mockk(relaxed = true) { + every { isEnabled(any()) } returns true + } + service = InAppWebViewPrewarmManagerImpl( engine = engine, mobileConfigSerializationManager = MobileConfigSerializationManagerImpl(gson = configGson()), gatewayManager = lazyOf(gatewayManager), @@ -96,7 +102,8 @@ internal class InAppWebViewPrewarmerImplTest { webViewLayerValidator = WebViewLayerValidator(), learnedHostsStore = mockk(relaxed = true) { every { hosts(any()) } returns listOf("learned-cdn.mindbox.ru") - } + }, + featureToggleManager = featureToggleManager ) } @@ -282,6 +289,36 @@ internal class InAppWebViewPrewarmerImplTest { verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } } + @Test + fun `prewarmResources releases warm webview and skips warming when the feature toggle is off`() { + every { featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE) } returns false + + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { engine.release() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + coVerify(exactly = 0) { gatewayManager.fetchWebViewContent(any()) } + } + + @Test + fun `prewarmResources warms normally once the feature toggle turns back on`() { + every { featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE) } returns false + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + + every { featureToggleManager.isEnabled(PREWARM_INAPP_WEBVIEW_FEATURE) } returns true + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + @Test fun `real show preempts prewarm terminally and blocks later attempts`() { service.onRealShowWillStart() @@ -317,6 +354,25 @@ internal class InAppWebViewPrewarmerImplTest { verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } } + @Test + fun `prewarmOnInit does nothing when the cached config disables the feature toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = false) + + service.prewarmOnInit() + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `prewarmOnInit warms when the cached config explicitly enables the feature toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = true) + + service.prewarmOnInit() + + verify(exactly = 1) { engine.loadPreconnectPage(any(), "https://inapp.local/popup", any()) } + } + /** Same `${'$'}type` adapters the production gson registers for the form payload path. */ private fun configGson(): Gson = GsonBuilder() .registerTypeAdapterFactory( @@ -339,7 +395,11 @@ internal class InAppWebViewPrewarmerImplTest { ) .create() - private fun cachedConfigJson(): String = """ + private fun cachedConfigJson(prewarmToggle: Boolean? = null): String { + val settings = prewarmToggle?.let { + ", \"settings\": { \"featureToggles\": { \"MobileSdkShouldPrewarmInAppWebView\": $it } }" + }.orEmpty() + return """ { "inapps": [ { @@ -367,7 +427,8 @@ internal class InAppWebViewPrewarmerImplTest { ] } } - ] + ]$settings } - """.trimIndent() + """.trimIndent() + } } From 1e48ba434817a08e8802d6e0e4dacb8ef923beb0 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 10 Jul 2026 19:10:26 +0300 Subject: [PATCH 31/37] MOBILE-269: Add tests --- kmp-common-sdk | 2 +- .../inapp/presentation/InAppWebViewPrewarmManager.kt | 5 ++++- .../inapp/presentation/view/WebViewInappViewHolder.kt | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/kmp-common-sdk b/kmp-common-sdk index 1ab60a84..e4fe2629 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit 1ab60a841b5990ee4cc32b7d25acb369249b6458 +Subproject commit e4fe2629533ed913e4e47d53cd51db5ada7be907 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt index df7a2eda..85ead834 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt @@ -337,7 +337,10 @@ internal class InAppWebViewPrewarmManagerImpl( private fun webViewLayers(configString: String): List { val configBlank = mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) ?: return emptyList() - if (!isPrewarmEnabled(configBlank)) return emptyList() + if (!isPrewarmEnabled(configBlank)) { + mindboxLogI("[WebView] Prewarm: feature toggle is off in the cached config, skipping head start") + return emptyList() + } return configBlank.inApps.orEmpty() // Same version gate as the real pipeline: in-apps for other SDK versions may // carry form formats this version cannot even deserialize. diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 194b8f34..008551a9 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -430,7 +430,8 @@ internal class WebViewInAppViewHolder( val controller: WebViewController = WebViewController.create( context = currentDialog.context, isDebugEnabled = BuildConfig.DEBUG, - isCacheEnabled = featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE) + isCacheEnabled = featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE), + log = { message -> mindboxLogI("[WebView] $message") } ) val view: WebViewPlatformView = controller.view view.layoutParams = RelativeLayout.LayoutParams( From 52a982ed32c5ab895b0f5559976e3b91f932cd72 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 14 Jul 2026 10:43:18 +0300 Subject: [PATCH 32/37] MOBILE-269: Add InAppWebViewCachePolicy --- .gitmodules | 2 +- kmp-common-sdk | 2 +- .../mobile_sdk/di/modules/DataModule.kt | 10 +- .../mobile_sdk/di/modules/MindboxModule.kt | 1 + .../presentation/InAppWebViewCachePolicy.kt | 55 ++++++++ .../InAppWebViewPrewarmManager.kt | 14 +- .../view/WebViewInappViewHolder.kt | 7 +- .../InAppWebViewCachePolicyTest.kt | 125 ++++++++++++++++++ .../InAppWebViewPrewarmManagerImplTest.kt | 45 ++++++- 9 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt diff --git a/.gitmodules b/.gitmodules index db7f9ca9..aa354a45 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "kmp-common-sdk"] path = kmp-common-sdk url = https://github.com/mindbox-cloud/kmp-common-sdk.git - branch = prototype/webview-cache-profiling + branch = develop \ No newline at end of file diff --git a/kmp-common-sdk b/kmp-common-sdk index e4fe2629..bddef579 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit e4fe2629533ed913e4e47d53cd51db5ada7be907 +Subproject commit bddef5793ab0d1c3741e7702afbe89870f98f059 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt index 734e0aad..4f3ec535 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DataModule.kt @@ -26,6 +26,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSer import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.* import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.validators.InAppValidator import cloud.mindbox.mobile_sdk.inapp.presentation.InAppMessageDelayedManager +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewCachePolicy import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManagerImpl import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager @@ -149,12 +150,16 @@ internal fun DataModule( ) } + override val webViewCachePolicy: InAppWebViewCachePolicy by lazy { + InAppWebViewCachePolicy(mobileConfigSerializationManager) + } + override val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager by lazy { InAppWebViewPrewarmManagerImpl( engine = InAppWebViewPrewarmEngine( appContext = appContext, log = { message -> mindboxLogI("[WebView] Prewarm: $message") }, - isCacheEnabled = { featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE) } + isCacheEnabled = { webViewCachePolicy.isCacheEnabled } ), mobileConfigSerializationManager = mobileConfigSerializationManager, // Lazy on purpose: this service is resolved on the main thread during SDK init @@ -164,7 +169,8 @@ internal fun DataModule( inAppValidator = inAppValidator, webViewLayerValidator = webViewLayerValidator, learnedHostsStore = InAppWebViewLearnedHostsStore(), - featureToggleManager = featureToggleManager + featureToggleManager = featureToggleManager, + webViewCachePolicy = webViewCachePolicy ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt index ddfc9f23..696a863d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/MindboxModule.kt @@ -65,6 +65,7 @@ internal interface DataModule : MindboxModule { val mobileConfigRepository: MobileConfigRepository val mobileConfigSerializationManager: MobileConfigSerializationManager val inAppWebViewPrewarmManager: InAppWebViewPrewarmManager + val webViewCachePolicy: InAppWebViewCachePolicy val inAppGeoRepository: InAppGeoRepository val inAppRepository: InAppRepository val callbackRepository: CallbackRepository diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt new file mode 100644 index 00000000..c543471a --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicy.kt @@ -0,0 +1,55 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.inapp.data.managers.CACHE_INAPP_WEBVIEW_FEATURE +import cloud.mindbox.mobile_sdk.inapp.data.managers.FEATURE_TOGGLE_DEFAULT +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager +import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponseBlank +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences + +/** + * The cache half of the WebView feature toggles (`MobileSdkShouldCacheInAppWebView`), + * latched once per process from the cached config on disk — mirrors iOS's + * `InAppWebViewDataStore.isCacheFeatureEnabled`. + * + * Read from the cache, not [cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager]: + * the prewarm's first WebView is created at SDK init, before any fresh config can populate + * the manager — reading the live toggle there always sees the default (enabled), even when + * the cached config already says otherwise. The decision is latched for the whole launch by + * design: prewarm and every later show must agree, or the cache would be split between two + * behaviors for the same session. This is also why the value can't just live in + * [cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager]'s toggle + * map: that map is cleared and repopulated from the fresh config on every fetch, which would + * silently un-latch this decision the first time a fresh config disagreed with the cache. + */ +internal class InAppWebViewCachePolicy( + private val mobileConfigSerializationManager: MobileConfigSerializationManager +) { + + @Volatile + private var latched: Boolean? = null + + val isCacheEnabled: Boolean + @Synchronized get() = latched ?: extract(parseCachedConfigBlank()).also { latched = it } + + /** + * Lets a caller that already parsed the cached config blank for its own purposes (the + * prewarm manager, which needs the same blank for its layers and prewarm-toggle checks) + * hand it over instead of this class deserializing the same JSON a second time. + * + * First value wins: a call after the toggle has already latched — from here or from + * [isCacheEnabled] itself — is a no-op. + */ + @Synchronized + fun prime(configBlank: InAppConfigResponseBlank?) { + if (latched == null) latched = extract(configBlank) + } + + private fun extract(configBlank: InAppConfigResponseBlank?): Boolean = + configBlank?.settings?.featureToggles?.toggles?.get(CACHE_INAPP_WEBVIEW_FEATURE) ?: FEATURE_TOGGLE_DEFAULT + + private fun parseCachedConfigBlank(): InAppConfigResponseBlank? { + val configString = MindboxPreferences.inAppConfig + if (configString.isBlank()) return null + return mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) + } +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt index 85ead834..52b9f770 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt @@ -28,6 +28,7 @@ import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponseBla import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import cloud.mindbox.mobile_sdk.utils.loggingRunCatchingSuspending +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -38,6 +39,7 @@ import org.json.JSONArray import org.json.JSONTokener import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration.Companion.milliseconds /** * Production prewarm for webview in-apps. Two stages, both driven by the mobile @@ -74,6 +76,7 @@ internal interface InAppWebViewPrewarmManager { fun onRealShowWillStart() /** Records the https hosts the shown page actually used (learned-hosts store). */ + @OptIn(InternalMindboxApi::class) fun captureObservedHosts(controller: WebViewController) } @@ -85,7 +88,8 @@ internal class InAppWebViewPrewarmManagerImpl( private val inAppValidator: InAppValidator, private val webViewLayerValidator: WebViewLayerValidator, private val learnedHostsStore: InAppWebViewLearnedHostsStore, - private val featureToggleManager: FeatureToggleManager + private val featureToggleManager: FeatureToggleManager, + private val webViewCachePolicy: InAppWebViewCachePolicy ) : InAppWebViewPrewarmManager { companion object { @@ -271,7 +275,7 @@ internal class InAppWebViewPrewarmManagerImpl( var stablePolls = 0 var idle = false while (polls < budgetPolls) { - delay(IDLE_POLL_MS) + delay(IDLE_POLL_MS.milliseconds) polls++ if (hasAborted.get()) return@launch val probe = probeResourceState() @@ -306,8 +310,9 @@ internal class InAppWebViewPrewarmManagerImpl( * returns garbage (WebView gone/aborted, page not ready) — callers just keep polling * until the hard cap. The 2s timeout guards against a callback that never fires. */ + @OptIn(ExperimentalCoroutinesApi::class) private suspend fun probeResourceState(): ResourceProbe? = - withTimeoutOrNull(IDLE_QUIET_MS) { + withTimeoutOrNull(IDLE_QUIET_MS.milliseconds) { suspendCancellableCoroutine { continuation -> engine.evaluateJavaScript(IDLE_PROBE_JS) { rawResult -> // evaluateJavascript JSON-quotes string results: "\"6:3456\"". @@ -337,6 +342,9 @@ internal class InAppWebViewPrewarmManagerImpl( private fun webViewLayers(configString: String): List { val configBlank = mobileConfigSerializationManager.deserializeToConfigDtoBlank(configString) ?: return emptyList() + // Shares this parse with the cache toggle instead of it deserializing the same + // cached config a second time; a no-op once the toggle has already latched. + webViewCachePolicy.prime(configBlank) if (!isPrewarmEnabled(configBlank)) { mindboxLogI("[WebView] Prewarm: feature toggle is off in the cached config, skipping head start") return emptyList() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 008551a9..191d6909 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -14,18 +14,17 @@ import cloud.mindbox.mobile_sdk.* import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto -import cloud.mindbox.mobile_sdk.inapp.data.managers.CACHE_INAPP_WEBVIEW_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.validators.BridgeMessageValidator import cloud.mindbox.mobile_sdk.inapp.data.validators.HapticRequestValidator import cloud.mindbox.mobile_sdk.inapp.domain.extensions.executeWithFailureTracking import cloud.mindbox.mobile_sdk.inapp.domain.extensions.sendFailureWithContext import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.PermissionManager -import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppTypeWrapper import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.presentation.InAppCallback +import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewCachePolicy import cloud.mindbox.mobile_sdk.inapp.presentation.InAppWebViewPrewarmManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxNotificationManager import cloud.mindbox.mobile_sdk.inapp.presentation.MindboxView @@ -97,7 +96,7 @@ internal class WebViewInAppViewHolder( private val gson: Gson by mindboxInject { this.gson } private val timeProvider: TimeProvider by mindboxInject { timeProvider } private val webViewPrewarmManager: InAppWebViewPrewarmManager by mindboxInject { inAppWebViewPrewarmManager } - private val featureToggleManager: FeatureToggleManager by mindboxInject { featureToggleManager } + private val webViewCachePolicy: InAppWebViewCachePolicy by mindboxInject { webViewCachePolicy } private val messageValidator: BridgeMessageValidator by lazy { BridgeMessageValidator() } private val hapticRequestValidator: HapticRequestValidator by lazy { HapticRequestValidator() } private val gatewayManager: GatewayManager by mindboxInject { gatewayManager } @@ -430,7 +429,7 @@ internal class WebViewInAppViewHolder( val controller: WebViewController = WebViewController.create( context = currentDialog.context, isDebugEnabled = BuildConfig.DEBUG, - isCacheEnabled = featureToggleManager.isEnabled(CACHE_INAPP_WEBVIEW_FEATURE), + isCacheEnabled = webViewCachePolicy.isCacheEnabled, log = { message -> mindboxLogI("[WebView] $message") } ) val view: WebViewPlatformView = controller.view diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt new file mode 100644 index 00000000..88befd3f --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewCachePolicyTest.kt @@ -0,0 +1,125 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation + +import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationManagerImpl +import cloud.mindbox.mobile_sdk.repository.MindboxPreferences +import com.google.gson.Gson +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +internal class InAppWebViewCachePolicyTest { + + private val serializationManager = MobileConfigSerializationManagerImpl(gson = Gson()) + + @Before + fun setUp() { + mockkObject(MindboxPreferences) + } + + @After + fun tearDown() { + unmockkObject(MindboxPreferences) + } + + @Test + fun `isCacheEnabled is false when the cached config disables the toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = false) + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(false, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled is true when the cached config explicitly enables the toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = true) + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled defaults to true when the cached config has no toggle`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = null) + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled defaults to true when there is no cached config`() { + every { MindboxPreferences.inAppConfig } returns "" + + val policy = InAppWebViewCachePolicy(serializationManager) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `isCacheEnabled is latched for the process and ignores a later cached config change`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = false) + val policy = InAppWebViewCachePolicy(serializationManager) + assertEquals(false, policy.isCacheEnabled) + + // A fresh config lands and flips the cached toggle mid-session — the already + // latched decision must not change, or the WebView cache would be split between + // two behaviors within the same launch. + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = true) + + assertEquals(false, policy.isCacheEnabled) + } + + @Test + fun `prime latches the toggle from an already-parsed config blank without touching the cached config`() { + val configBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = false)) + val policy = InAppWebViewCachePolicy(serializationManager) + + // MindboxPreferences.inAppConfig is deliberately left unstubbed: if isCacheEnabled + // fell back to parsing the cache itself instead of using the primed value, this + // would throw on the unstubbed mock. + policy.prime(configBlank) + + assertEquals(false, policy.isCacheEnabled) + } + + @Test + fun `prime is a no-op once isCacheEnabled has already latched a value`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(cacheToggle = true) + val policy = InAppWebViewCachePolicy(serializationManager) + assertEquals(true, policy.isCacheEnabled) + + val disabledBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = false)) + policy.prime(disabledBlank) + + assertEquals(true, policy.isCacheEnabled) + } + + @Test + fun `prime keeps the first primed value on a later prime call`() { + val policy = InAppWebViewCachePolicy(serializationManager) + val enabledBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = true)) + val disabledBlank = serializationManager.deserializeToConfigDtoBlank(cachedConfigJson(cacheToggle = false)) + + policy.prime(enabledBlank) + policy.prime(disabledBlank) + + assertEquals(true, policy.isCacheEnabled) + } + + private fun cachedConfigJson(cacheToggle: Boolean?): String { + val toggle = cacheToggle?.let { "\"MobileSdkShouldCacheInAppWebView\": $it" }.orEmpty() + return """ + { + "settings": { + "featureToggles": { $toggle } + } + } + """.trimIndent() + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt index 5ff07197..0437c041 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt @@ -8,6 +8,7 @@ import cloud.mindbox.mobile_sdk.inapp.data.managers.MobileConfigSerializationMan import cloud.mindbox.mobile_sdk.inapp.data.managers.PREWARM_INAPP_WEBVIEW_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.validators.WebViewLayerValidator import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.MobileConfigSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.models.Form import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppConfig import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType @@ -27,6 +28,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.spyk import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.CoroutineScope @@ -36,6 +38,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -76,6 +79,8 @@ internal class InAppWebViewPrewarmManagerImplTest { private lateinit var engine: InAppWebViewPrewarmEngine private lateinit var gatewayManager: GatewayManager private lateinit var featureToggleManager: FeatureToggleManager + private lateinit var mobileConfigSerializationManager: MobileConfigSerializationManager + private lateinit var webViewCachePolicy: InAppWebViewCachePolicy private lateinit var service: InAppWebViewPrewarmManagerImpl @Before @@ -92,9 +97,13 @@ internal class InAppWebViewPrewarmManagerImplTest { featureToggleManager = mockk(relaxed = true) { every { isEnabled(any()) } returns true } + // spyk, not a plain instance: lets tests verify the cached config blank is + // deserialized only once and shared with the cache policy, not parsed twice. + mobileConfigSerializationManager = spyk(MobileConfigSerializationManagerImpl(gson = configGson())) + webViewCachePolicy = InAppWebViewCachePolicy(mobileConfigSerializationManager) service = InAppWebViewPrewarmManagerImpl( engine = engine, - mobileConfigSerializationManager = MobileConfigSerializationManagerImpl(gson = configGson()), + mobileConfigSerializationManager = mobileConfigSerializationManager, gatewayManager = lazyOf(gatewayManager), inAppValidator = mockk(relaxed = true) { every { validateInAppVersion(any()) } returns true @@ -103,7 +112,8 @@ internal class InAppWebViewPrewarmManagerImplTest { learnedHostsStore = mockk(relaxed = true) { every { hosts(any()) } returns listOf("learned-cdn.mindbox.ru") }, - featureToggleManager = featureToggleManager + featureToggleManager = featureToggleManager, + webViewCachePolicy = webViewCachePolicy ) } @@ -373,6 +383,25 @@ internal class InAppWebViewPrewarmManagerImplTest { verify(exactly = 1) { engine.loadPreconnectPage(any(), "https://inapp.local/popup", any()) } } + @Test + fun `prewarmOnInit primes the cache toggle from its own parse instead of a second deserialize`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = true, cacheToggle = false) + + service.prewarmOnInit() + + assertEquals(false, webViewCachePolicy.isCacheEnabled) + verify(exactly = 1) { mobileConfigSerializationManager.deserializeToConfigDtoBlank(any()) } + } + + @Test + fun `prewarmOnInit primes the cache toggle even when the prewarm toggle is off`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson(prewarmToggle = false, cacheToggle = false) + + service.prewarmOnInit() + + assertEquals(false, webViewCachePolicy.isCacheEnabled) + } + /** Same `${'$'}type` adapters the production gson registers for the form payload path. */ private fun configGson(): Gson = GsonBuilder() .registerTypeAdapterFactory( @@ -395,10 +424,14 @@ internal class InAppWebViewPrewarmManagerImplTest { ) .create() - private fun cachedConfigJson(prewarmToggle: Boolean? = null): String { - val settings = prewarmToggle?.let { - ", \"settings\": { \"featureToggles\": { \"MobileSdkShouldPrewarmInAppWebView\": $it } }" - }.orEmpty() + private fun cachedConfigJson(prewarmToggle: Boolean? = null, cacheToggle: Boolean? = null): String { + val toggles = listOfNotNull( + prewarmToggle?.let { "\"MobileSdkShouldPrewarmInAppWebView\": $it" }, + cacheToggle?.let { "\"MobileSdkShouldCacheInAppWebView\": $it" } + ) + val settings = toggles.takeIf { it.isNotEmpty() } + ?.let { ", \"settings\": { \"featureToggles\": { ${it.joinToString(", ")} } }" } + .orEmpty() return """ { "inapps": [ From 7c31a188290dccafbc0e2c9a5e14d2d3d491b5ba Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 14 Jul 2026 13:54:33 +0300 Subject: [PATCH 33/37] MOBILE-269: Follow code review --- .gitmodules | 2 +- kmp-common-sdk | 2 +- .../presentation/InAppWebViewPrewarmManager.kt | 7 +++++++ .../presentation/view/WebViewInappViewHolder.kt | 13 ++++++------- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.gitmodules b/.gitmodules index aa354a45..e60d3b3d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "kmp-common-sdk"] path = kmp-common-sdk url = https://github.com/mindbox-cloud/kmp-common-sdk.git - branch = develop \ No newline at end of file + branch = develop \ No newline at end of file diff --git a/kmp-common-sdk b/kmp-common-sdk index bddef579..6cfaa316 160000 --- a/kmp-common-sdk +++ b/kmp-common-sdk @@ -1 +1 @@ -Subproject commit bddef5793ab0d1c3741e7702afbe89870f98f059 +Subproject commit 6cfaa316f450a08fa936bfac681af2cbe75bff52 diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt index 52b9f770..e55345a1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt @@ -262,6 +262,10 @@ internal class InAppWebViewPrewarmManagerImpl( // looper for 30s during the live show). if (hasAborted.get()) return val job = Mindbox.mindboxScope.launch { + // Belt-and-suspenders with the per-tick check below: an abort landing between + // the guard above and this coroutine's first resumption must not run even one + // poll tick. + if (hasAborted.get()) return@launch // Budget in poll units instead of a wall clock read: the whole loop runs on // virtual time in tests. A null probe is charged the full probe timeout so the // cap stays a real wall-clock bound even when the evaluate callback never fires @@ -301,6 +305,9 @@ internal class InAppWebViewPrewarmManagerImpl( engine.release() } settleJob.getAndSet(job)?.cancel() + if (hasAborted.get()) { + settleJob.getAndSet(null)?.cancel() + } } private data class ResourceProbe(val entryCount: Int, val msSinceLastResponseEnd: Long) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 191d6909..c84b6e35 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -565,7 +565,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "JS ready check gave up for $url: $lastFailure" + errorDescription = "JS ready check gave up for $url: $lastFailure", + tags = wrapper.tags ) } ) @@ -588,9 +589,9 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "evaluateJavaScript return unexpected response: $response", - tags = wrapper.tags - ) + errorDescription = "evaluateJavaScript returned unexpected response: $response", + tags = wrapper.tags + ) } else { mindboxLogW("evaluateJavaScript miss after teardown (ignored): $response") } @@ -761,9 +762,7 @@ internal class WebViewInAppViewHolder( private fun onContentPageLoaded(content: WebViewHtmlContent) { webViewController?.let { controller -> - controller.executeOnViewThread { - controller.loadContent(content) - } + controller.loadContent(content) startTimer { inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, From 654fb670acec14c2ad35aab4292ea57cac6cb977 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Tue, 14 Jul 2026 17:21:59 +0300 Subject: [PATCH 34/37] MOBILE-269: Follow code review --- .gitmodules | 2 +- .../java/cloud/mindbox/mobile_sdk/Mindbox.kt | 1 + .../InAppWebViewPrewarmManager.kt | 50 +++++-- .../view/WebViewInappViewHolder.kt | 54 ++++++-- .../InAppWebViewPrewarmManagerImplTest.kt | 125 +++++++++++++++++- 5 files changed, 208 insertions(+), 24 deletions(-) diff --git a/.gitmodules b/.gitmodules index e60d3b3d..972e0874 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "kmp-common-sdk"] path = kmp-common-sdk url = https://github.com/mindbox-cloud/kmp-common-sdk.git - branch = develop \ No newline at end of file + branch = develop \ No newline at end of file diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index b147c270..6662d32d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -1380,6 +1380,7 @@ public object Mindbox : MindboxLog { private fun softReinitialization( context: Context, ) { + MindboxDI.appModule.inAppWebViewPrewarmManager.terminate() mindboxScope.cancel() DbManager.removeAllEventsFromQueue() BackgroundWorkManager.cancelAllWork(context) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt index e55345a1..e6ad0036 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManager.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.presentation +import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto @@ -78,6 +79,14 @@ internal interface InAppWebViewPrewarmManager { /** Records the https hosts the shown page actually used (learned-hosts store). */ @OptIn(InternalMindboxApi::class) fun captureObservedHosts(controller: WebViewController) + + /** + * Terminates the prewarm subsystem for good. Call before cancelling the coroutine scope + * this manager runs on (SDK teardown, soft reinitialization): a settle-poll job killed by + * scope cancellation never reaches its own tail-end `engine.release()`, so without this + * the warm WebView would leak until process death. + */ + fun terminate() } @OptIn(InternalMindboxApi::class) @@ -124,10 +133,24 @@ internal class InAppWebViewPrewarmManagerImpl( private val settleJob = AtomicReference(null) override fun prewarmOnInit() { + // Cheap short-circuit before paying for the config read/parse below: a repeat + // initialize() call must not redo work a prior attempt already finished. + if (hasStartedResourcePrewarm.get()) return Mindbox.mindboxScope.launch { loggingRunCatchingSuspending { + // Other init-time readers wait for this; without it, a migration that fails + // and triggers a softReset could erase the cached config out from under a + // prewarm that already read it. + InitializeLock.await(InitializeLock.State.MIGRATION) val cachedConfig = MindboxPreferences.inAppConfig - if (cachedConfig.isBlank()) return@loggingRunCatchingSuspending + if (cachedConfig.isBlank()) { + // Nothing to prewarm, but the cache toggle still needs a decision for + // the first real show — latch it to the default now, off the main + // thread, instead of parsing lazily (nothing to parse anyway) the + // moment isCacheEnabled is first read during that show. + webViewCachePolicy.prime(null) + return@loggingRunCatchingSuspending + } val layers = webViewLayers(cachedConfig) if (layers.isEmpty()) return@loggingRunCatchingSuspending mindboxLogI("[WebView] Prewarm: head start from cached config (${layers.size} webview layer(s))") @@ -169,11 +192,15 @@ internal class InAppWebViewPrewarmManagerImpl( engine.release() } - override fun onRealShowWillStart() { + override fun onRealShowWillStart() = abortPermanently() + + override fun terminate() = abortPermanently() + + private fun abortPermanently() { hasAborted.set(true) settleJob.getAndSet(null)?.cancel() // Terminal: the engine latches synchronously, so a prewarm load already posted from - // a background thread cannot resurrect the WebView mid-show. + // a background thread cannot resurrect the WebView afterward. engine.abort() } @@ -214,11 +241,12 @@ internal class InAppWebViewPrewarmManagerImpl( mindboxLogW("[WebView] Prewarm: no valid webview layer urls in config, skipping") return } - if (!hasStartedResourcePrewarm.compareAndSet(false, true)) return - // Re-check after the configuration read suspension: a fresh no-layers config that - // landed while this (init-triggered) prewarm was suspended must keep the WebView - // from ever being created — release() posted at that point was a no-op. + // Re-check after the configuration read suspension BEFORE the one-shot CAS: an + // attempt that stumbles here must not consume it, or a fresh no-layers config + // landing mid-suspension would burn the one-shot on a prewarm that never reaches + // the engine, per this function's own doc comment. if (hasAborted.get() || latestConfigHasNoLayers.get()) return + if (!hasStartedResourcePrewarm.compareAndSet(false, true)) return val userAgentSuffix = configuration.getShortUserAgent() mindboxLogI("[WebView] Prewarm: preconnect to ${plan.preconnectOrigins.joinToString(",")} under ${plan.baseUrl}") @@ -359,16 +387,22 @@ internal class InAppWebViewPrewarmManagerImpl( return configBlank.inApps.orEmpty() // Same version gate as the real pipeline: in-apps for other SDK versions may // carry form formats this version cannot even deserialize. + .asSequence() .filter { inAppBlank -> inAppValidator.validateInAppVersion(inAppBlank) } .flatMap { inAppBlank -> mobileConfigSerializationManager.deserializeToInAppFormDto(inAppBlank.form) ?.variants.orEmpty() .filterIsInstance() - .flatMap { modal -> modal.content?.background?.layers.orEmpty() } + // Same gate as the real pipeline (InAppMapper): a modal only becomes a + // WebView in-app when webview is its FIRST layer — collecting every + // webview layer regardless of position would prewarm modals that will + // never actually show as a webview, burning the one-shot on them. + .mapNotNull { modal -> modal.content?.background?.layers?.firstOrNull() } } .filterIsInstance() .filter { layerDto -> webViewLayerValidator.isValid(layerDto) } .map { layerDto -> InAppWebViewPrewarmLayer(baseUrl = layerDto.baseUrl, contentUrl = layerDto.contentUrl) } + .toList() } private fun isPrewarmEnabled(configBlank: InAppConfigResponseBlank): Boolean = diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index c84b6e35..be2d52ce 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -84,6 +84,9 @@ internal class WebViewInAppViewHolder( private var readyChecker: WebViewReadyChecker? = null private val mainHandler: Handler = Handler(Looper.getMainLooper()) + private var hasInitialized = false + private var pendingReadyCheckFailure: String? = null + private var motionService: MotionServiceProtocol? = null private fun bindWebViewBackAction(currentRoot: MindboxView, controller: WebViewController) { @@ -286,6 +289,7 @@ internal class WebViewInAppViewHolder( } private fun handleInitAction(controller: WebViewController): String { + hasInitialized = true stopTimer() wrapper.inAppActionCallbacks.onInAppShown.onShown() val mindboxView = currentMindboxView ?: run { @@ -325,8 +329,6 @@ internal class WebViewInAppViewHolder( private fun handleCloseAction(message: BridgeMessage): String { motionService?.stopMonitoring() - // Remember which https hosts this show actually used — feeds the next launch's preconnect. - webViewController?.let { controller -> webViewPrewarmManager.captureObservedHosts(controller) } inAppCallback.onInAppDismissed(wrapper.inAppType.inAppId) mindboxLogI("In-app dismissed by webview action ${message.action} with payload ${message.payload}") inAppController.close() @@ -538,10 +540,13 @@ internal class WebViewInAppViewHolder( * [sendActionInternal] intentionally stays single-shot ([checkEvaluateJavaScript]) — that * path talks to a page that already proved itself ready. * - * Give-up records the failure but does NOT close: the init timer is the closing - * authority. The checker's ~1.2s budget can expire while an allowed same-origin - * navigation is still in flight or while a slow page is still booting its bridge — - * cases the timer would have accepted (the window stays invisible until `init` anyway). + * Give-up BEFORE `init` only records the failure ([pendingReadyCheckFailure]) — the + * init timer is the closing authority there, since the checker's ~1.2s budget can + * expire while an allowed same-origin navigation is still in flight or a slow page is + * still booting its bridge (cases the timer would have accepted; the window stays + * invisible until `init` anyway). Give-up AFTER `init` closes immediately instead: a + * same-origin navigation broke a bridge that had already proven itself alive, and + * there is no init timer left to catch it. */ private fun startReadyCheck(url: String?) { // A late onPageFinished can be queued on the main looper when the in-app closes; @@ -562,12 +567,17 @@ internal class WebViewInAppViewHolder( expectedResult = JS_RETURN, onReady = { mindboxLogD("JS ready check passed for $url") }, onGiveUp = { lastFailure -> - inAppFailureTracker.sendFailureWithContext( - inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "JS ready check gave up for $url: $lastFailure", - tags = wrapper.tags - ) + if (hasInitialized) { + inAppFailureTracker.sendFailureWithContext( + inAppId = wrapper.inAppType.inAppId, + failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, + errorDescription = "JS ready check gave up after init for $url: $lastFailure", + tags = wrapper.tags + ) + inAppController.close() + } else { + pendingReadyCheckFailure = lastFailure + } } ) } @@ -762,12 +772,26 @@ internal class WebViewInAppViewHolder( private fun onContentPageLoaded(content: WebViewHtmlContent) { webViewController?.let { controller -> + hasInitialized = false + pendingReadyCheckFailure = null controller.loadContent(content) startTimer { + // A ready-check give-up that already fired before init is the more specific + // reason this timed out — report it instead of the generic load-timeout so + // the "ready check never passed" category isn't lost. + val readyCheckFailure = pendingReadyCheckFailure inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, - failureReason = FailureReason.WEBVIEW_LOAD_FAILED, - errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}.", + failureReason = if (readyCheckFailure != null) { + FailureReason.WEBVIEW_PRESENTATION_FAILED + } else { + FailureReason.WEBVIEW_LOAD_FAILED + }, + errorDescription = if (readyCheckFailure != null) { + "JS ready check never passed before init timeout: $readyCheckFailure" + } else { + "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." + }, tags = wrapper.tags ) controller.executeOnViewThread { @@ -836,6 +860,8 @@ internal class WebViewInAppViewHolder( readyChecker = null cancelPendingResponses("WebView In-App is closed") webViewController?.let { controller -> + // Remember which https hosts this show actually used — feeds the next launch's preconnect. + webViewPrewarmManager.captureObservedHosts(controller) // Detach first: a page event already queued on the main looper must not reach // this torn-down holder (e.g. a late onPageFinished spawning a ready checker). controller.setEventListener(null) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt index 0437c041..c7faa4e3 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppWebViewPrewarmManagerImplTest.kt @@ -1,6 +1,8 @@ package cloud.mindbox.mobile_sdk.inapp.presentation +import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.inapp.data.dto.BackgroundDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadBlankDto import cloud.mindbox.mobile_sdk.inapp.data.dto.PayloadDto @@ -42,7 +44,7 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, InternalMindboxApi::class) internal class InAppWebViewPrewarmManagerImplTest { private val configuration = Configuration( @@ -91,6 +93,8 @@ internal class InAppWebViewPrewarmManagerImplTest { every { DbManager.listenConfigurations() } returns flowOf(configuration) mockkObject(MindboxPreferences) every { MindboxPreferences.deviceUuid } returns "test-device-uuid" + mockkObject(InitializeLock) + coEvery { InitializeLock.await(any()) } returns Unit engine = mockk(relaxed = true) gatewayManager = mockk(relaxed = true) coEvery { gatewayManager.fetchWebViewContent(any()) } returns "" @@ -122,6 +126,7 @@ internal class InAppWebViewPrewarmManagerImplTest { unmockkObject(Mindbox) unmockkObject(DbManager) unmockkObject(MindboxPreferences) + unmockkObject(InitializeLock) } private fun configWith(vararg inApps: cloud.mindbox.mobile_sdk.inapp.domain.models.InApp) = InAppConfig( @@ -290,6 +295,32 @@ internal class InAppWebViewPrewarmManagerImplTest { } } + @Test + fun `an attempt that trips the no-layers recheck does not consume the one-shot`() { + // The fresh no-layers config lands while this attempt is suspended reading the + // saved configuration — before the CAS. Per the doc comment, only an attempt that + // reaches the engine may consume the one-shot: this one must not. + every { DbManager.listenConfigurations() } answers { + service.prewarmResources(configWith(InAppStub.getInApp())) + flowOf(configuration) + } + + service.prewarmResources(configWith(webViewInApp)) + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + + // A later, valid attempt must still be able to warm — the one-shot survived. + every { DbManager.listenConfigurations() } returns flowOf(configuration) + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { + engine.loadContentPage( + any(), + "https://inapp.local/popup?prewarm=1&endpointId=Test.Endpoint&deviceUuid=test-device-uuid", + any() + ) + } + } + @Test fun `prewarmResources releases warm webview when config has no webview inapps`() { service.prewarmResources(configWith(InAppStub.getInApp())) @@ -339,6 +370,34 @@ internal class InAppWebViewPrewarmManagerImplTest { verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } } + @Test + fun `terminate aborts the engine and blocks later attempts, same as a real show`() { + service.terminate() + service.prewarmResources(configWith(webViewInApp)) + + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + + @Test + fun `terminate stops an in-flight settle poll like onRealShowWillStart does`() = runTest { + every { Mindbox.mindboxScope } returns CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + var count = 0 + every { engine.evaluateJavaScript(any(), any()) } answers { + count++ + secondArg<(String?) -> Unit>().invoke("\"$count:100\"") + } + + service.prewarmResources(configWith(webViewInApp)) + advanceTimeBy(2_500) + service.terminate() + advanceTimeBy(60_000) + + verify(exactly = 1) { engine.abort() } + verify(exactly = 0) { engine.release() } + } + @Test fun `prewarmOnInit warms from cached config without validation pipeline`() { every { MindboxPreferences.inAppConfig } returns cachedConfigJson() @@ -355,6 +414,70 @@ internal class InAppWebViewPrewarmManagerImplTest { } } + @Test + fun `prewarmOnInit awaits the migration lock before reading the cached config`() { + every { MindboxPreferences.inAppConfig } returns cachedConfigJson() + + service.prewarmOnInit() + + coVerify(exactly = 1) { InitializeLock.await(InitializeLock.State.MIGRATION) } + } + + @Test + fun `prewarmOnInit is a no-op once a prior attempt already reached the engine`() { + // Reaches the engine and consumes the one-shot. + service.prewarmResources(configWith(webViewInApp)) + + service.prewarmOnInit() + + // Not even the cheap cached-config read happens on the short-circuited path. + verify(exactly = 0) { MindboxPreferences.inAppConfig } + coVerify(exactly = 0) { InitializeLock.await(any()) } + } + + @Test + fun `prewarmOnInit does not warm a modal whose first layer is not webview`() { + // Same gate as the real pipeline (InAppMapper): webview must be the FIRST layer of + // the modal to ever become a WebView in-app; an image-then-webview modal never will. + every { MindboxPreferences.inAppConfig } returns """ + { + "inapps": [ + { + "id": "cached-webview", + "sdkVersion": { "min": 1, "max": null }, + "targeting": { "${'$'}type": "true" }, + "form": { + "variants": [ + { + "${'$'}type": "modal", + "content": { + "background": { + "layers": [ + { "${'$'}type": "image" }, + { + "${'$'}type": "webview", + "baseUrl": "https://inapp.local/popup", + "contentUrl": "https://mobile-static.mindbox.ru/content/index.html", + "params": { "formId": "159510" } + } + ] + }, + "elements": [] + } + } + ] + } + } + ] + } + """.trimIndent() + + service.prewarmOnInit() + + verify(exactly = 0) { engine.loadPreconnectPage(any(), any(), any()) } + verify(exactly = 0) { engine.loadContentPage(any(), any(), any()) } + } + @Test fun `prewarmOnInit does nothing without cached config`() { every { MindboxPreferences.inAppConfig } returns "" From f3c0e927f954c387a5c4bb7662eb76ea65bcc6a1 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:14:22 +0300 Subject: [PATCH 35/37] MOBILE-197: Send data-only payload in WebView sync onError (#736) * MOBILE-197: Send data-only payload in WebView sync onError Drop both wrappers: the {type, data} envelope from toJson and the {"error": ...} double serialization in sendErrorResponse. The payload format is shared with iOS: string httpStatusCode, no transport statusCode. toJson() is unchanged - public API used by wrapper SDKs. * MOBILE-197: Pin iOS-format sync error payloads in executor tests * MOBILE-197: Pin why sync error payload avoids gson.toJson The injected gson has htmlSafe enabled and would emit \u003c-style escapes, diverging from iOS JSONEncoder output. --- .../view/WebViewInappViewHolder.kt | 11 ++- .../view/WebViewOperationExecutor.kt | 2 +- .../view/WebViewSyncOperationError.kt | 57 +++++++++++++ .../view/WebViewOperationExecutorTest.kt | 83 +++++++++++++++++-- 4 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt 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 be2d52ce..7ef1db80 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 @@ -644,10 +644,13 @@ internal class WebViewInAppViewHolder( error: Throwable, controller: WebViewController, ) { - val json: String = runCatching { - val payload = ErrorPayload(error = requireNotNull(error.message)) - gson.toJson(payload) - }.getOrDefault(BridgeMessage.UNKNOWN_ERROR_PAYLOAD) + val json: String = when (error) { + is WebViewSyncOperationException -> error.payloadJson + else -> runCatching { + val payload = ErrorPayload(error = requireNotNull(error.message)) + gson.toJson(payload) + }.getOrDefault(BridgeMessage.UNKNOWN_ERROR_PAYLOAD) + } val errorMessage: BridgeMessage.Error = BridgeMessage.createErrorAction(message, json) mindboxLogE("WebView send error response for ${message.action} with payload ${errorMessage.payload}") diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt index a7f9f54f..24ca375a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt @@ -52,7 +52,7 @@ internal class MindboxWebViewOperationExecutor( onError = { error: MindboxError -> if (continuation.isActive) { continuation.resumeWithException( - IllegalStateException(error.toJson()) + WebViewSyncOperationException(error.toWebViewDataJson(gson)) ) } }, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt new file mode 100644 index 00000000..e277b856 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewSyncOperationError.kt @@ -0,0 +1,57 @@ +package cloud.mindbox.mobile_sdk.inapp.presentation.view + +import cloud.mindbox.mobile_sdk.models.MindboxError +import com.google.gson.Gson +import com.google.gson.JsonObject + +internal class WebViewSyncOperationException(val payloadJson: String) : Exception(payloadJson) + +/** + * Data-only JSON without the `{type, data}` envelope — the WebView JS-bridge `onError` + * contract shared with iOS: string `httpStatusCode`, no transport `statusCode`. + * `toJson()` must keep the envelope: RN/Flutter wrappers dispatch on it. + * + * Serialized via `JsonElement.toString()`, not `gson.toJson`: the SDK gson has + * htmlSafe enabled and would escape `<`/`&`/`'`, while iOS `JSONEncoder` does not. + */ +internal fun MindboxError.toWebViewDataJson(gson: Gson): String { + val data = JsonObject() + when (this) { + is MindboxError.Validation -> { + data.addProperty("status", status) + data.add("validationMessages", gson.toJsonTree(validationMessages)) + } + + is MindboxError.Protocol -> + data.addServerErrorFields(status, errorMessage, errorId, httpStatusCode) + + is MindboxError.InternalServer -> + data.addServerErrorFields(status, errorMessage, errorId, httpStatusCode) + + is MindboxError.UnknownServer -> { + status?.let { data.addProperty("status", it) } + errorMessage?.let { data.addProperty("errorMessage", it) } + errorId?.let { data.addProperty("errorId", it) } + data.addProperty("httpStatusCode", httpStatusCode?.toString() ?: "null") + } + + is MindboxError.Unknown -> { + data.addProperty("errorKey", "unknown") + data.addProperty("errorName", throwable?.javaClass?.canonicalName ?: "") + data.addProperty("errorMessage", throwable?.localizedMessage ?: "") + } + } + return data.toString() +} + +private fun JsonObject.addServerErrorFields( + status: String, + errorMessage: String?, + errorId: String?, + httpStatusCode: Int?, +) { + addProperty("status", status) + errorMessage?.let { addProperty("errorMessage", it) } + addProperty("errorId", errorId ?: "") + addProperty("httpStatusCode", httpStatusCode?.toString() ?: "null") +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt index 4138f21f..e03426d7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt @@ -3,8 +3,10 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Application import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.MindboxError +import cloud.mindbox.mobile_sdk.models.ValidationMessage import com.google.gson.Gson import io.mockk.* +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals @@ -290,10 +292,8 @@ class WebViewOperationExecutorTest { } } - @Test - fun `executeSyncOperation throws IllegalStateException when event manager returns error`() = runTest { + private fun executeSyncOperationExpectingError(error: MindboxError): WebViewSyncOperationException = runBlocking { val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" - val expectedError: MindboxError = MindboxError.Unknown(Throwable("network failure")) every { MindboxEventManager.syncOperation( name = any(), @@ -303,16 +303,85 @@ class WebViewOperationExecutorTest { ) } answers { val onError: (MindboxError) -> Unit = arg(3) - onError(expectedError) + onError(error) } try { executor.executeSyncOperation(payload, tags = null) - fail("Expected IllegalStateException") - } catch (exception: IllegalStateException) { - assertEquals(expectedError.toJson(), exception.message) + throw AssertionError("Expected WebViewSyncOperationException") + } catch (exception: WebViewSyncOperationException) { + exception } } + @Test + fun `executeSyncOperation protocol error payload is the data contents in iOS format`() { + val exception = executeSyncOperationExpectingError( + MindboxError.Protocol( + statusCode = 400, + status = "ProtocolError", + errorMessage = "Operation OpenScreen not found", + errorId = "error-id-1", + httpStatusCode = 400, + ) + ) + assertEquals( + """{"status":"ProtocolError","errorMessage":"Operation OpenScreen not found","errorId":"error-id-1","httpStatusCode":"400"}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation internal server error payload is the data contents in iOS format`() { + val exception = executeSyncOperationExpectingError( + MindboxError.InternalServer( + statusCode = 500, + status = "InternalServerError", + errorMessage = "Something went wrong", + errorId = null, + httpStatusCode = 500, + ) + ) + assertEquals( + """{"status":"InternalServerError","errorMessage":"Something went wrong","errorId":"","httpStatusCode":"500"}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation validation error payload is the data contents with validationMessages`() { + val exception = executeSyncOperationExpectingError( + MindboxError.Validation( + statusCode = 200, + status = "ValidationError", + validationMessages = listOf( + ValidationMessage(message = "Invalid email", location = "/customer/email") + ), + ) + ) + assertEquals( + """{"status":"ValidationError","validationMessages":[{"message":"Invalid email","location":"/customer/email"}]}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation network error payload is the data contents without envelope`() { + val exception = executeSyncOperationExpectingError(MindboxError.UnknownServer()) + assertEquals( + """{"errorMessage":"Cannot reach server","httpStatusCode":"null"}""", + exception.payloadJson, + ) + } + + @Test + fun `executeSyncOperation unknown error payload is the data contents without envelope`() { + val exception = executeSyncOperationExpectingError(MindboxError.Unknown(Throwable("network failure"))) + assertEquals( + """{"errorKey":"unknown","errorName":"java.lang.Throwable","errorMessage":"network failure"}""", + exception.payloadJson, + ) + } + @Test fun `executeSyncOperation throws when payload misses body`() = runTest { val payload: String = """{"operation":"OpenScreen"}""" From 97038c59554dd874b3189a42eeb77745ab1dfff9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:49:21 +0300 Subject: [PATCH 36/37] deps(deps): bump actions/setup-java in the github-actions group Bumps the github-actions group with 1 update: [actions/setup-java](https://github.com/actions/setup-java). Updates `actions/setup-java` from 5.5.0 to 5.6.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/0f481fcb613427c0f801b606911222b5b6f3083a...03ad4de0992f5dab5e18fcb136590ce7c4a0ac95) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lint_unitTests_build.yml | 6 +++--- .github/workflows/publish-reusable.yml | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index 0cad4984..94e849bf 100644 --- a/.github/workflows/lint_unitTests_build.yml +++ b/.github/workflows/lint_unitTests_build.yml @@ -24,7 +24,7 @@ jobs: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' @@ -75,7 +75,7 @@ jobs: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' @@ -125,7 +125,7 @@ jobs: submodules: true - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/publish-reusable.yml b/.github/workflows/publish-reusable.yml index c7814c46..bab01df5 100644 --- a/.github/workflows/publish-reusable.yml +++ b/.github/workflows/publish-reusable.yml @@ -35,7 +35,7 @@ jobs: submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' @@ -54,7 +54,7 @@ jobs: submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' @@ -74,7 +74,7 @@ jobs: submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' @@ -119,7 +119,7 @@ jobs: ref: ${{ inputs.branch }} submodules: recursive - name: Set up JDK 17 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '17' From 299db125044c292154d219286102038ae318b620 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:27:02 +0000 Subject: [PATCH 37/37] Bump SDK version to 2.15.3 --- example/app/build.gradle | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/app/build.gradle b/example/app/build.gradle index 47f8ba56..357f6310 100644 --- a/example/app/build.gradle +++ b/example/app/build.gradle @@ -107,7 +107,7 @@ dependencies { implementation 'com.google.code.gson:gson:2.11.0' // Mindbox - implementation 'cloud.mindbox:mobile-sdk:2.15.2' + implementation 'cloud.mindbox:mobile-sdk:2.15.3' implementation 'cloud.mindbox:mindbox-firebase' implementation 'cloud.mindbox:mindbox-huawei' implementation 'cloud.mindbox:mindbox-rustore' diff --git a/gradle.properties b/gradle.properties index fe509537..698429a8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,7 +20,7 @@ android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # SDK version property -SDK_VERSION_NAME=2.15.2 +SDK_VERSION_NAME=2.15.3 USE_LOCAL_MINDBOX_COMMON=true android.nonTransitiveRClass=false kotlin.mpp.androidGradlePluginCompatibility.nowarn=true