From 70913b08ede93f7380dbb4706dafa0a39a6bb094 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Mon, 18 May 2026 17:54:46 +0300 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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 }