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,140 @@ 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 {
+ InitializeLock.await(InitializeLock.State.SAVE_MINDBOX_CONFIG)
+ sendTrackVisitEvent(
+ MindboxDI.appModule.appContext,
+ source,
+ requestUrl,
+ )
+ }
}
}
}
@@ -888,12 +874,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 (Mindbox::lifecycleManager.isInitialized) {
- 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/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..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
@@ -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,293 @@ 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
+
+ internal val isRegister: Boolean get() = instance != null
- private const val TIMER_PERIOD = 1200000L
- private const val MAX_INTENT_HASHES_SIZE = 50
+ 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
+ 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",
+ )
+ }
+
+ // 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
+ }
+ }
+ }
}
- 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.
+ */
+ @Volatile
+ 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
}
- sendTrackVisit(activity.intent, areActivitiesEqual)
+ sendTrackVisit(intent, 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)
- }
-
- override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) {
+ callbacks?.onActivityStopped(activity)
}
- 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,
+ intent: Intent?,
+ 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..61ddcca1
--- /dev/null
+++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt
@@ -0,0 +1,29 @@
+package cloud.mindbox.mobile_sdk.managers
+
+import android.content.Context
+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.mindboxLogI
+
+/**
+ * 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
+
+ mindboxLogI("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..f36c32e4
--- /dev/null
+++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt
@@ -0,0 +1,170 @@
+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.*
+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 keeps the first instance`() {
+ 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)
+ assertSame(
+ "second create must be a no-op — the existing instance must be kept to avoid a leaked observer",
+ firstInstance,
+ LifecycleManager.instance,
+ )
+ }
+
+ @Test
+ fun `isAppInBackground true - suppresses track visit on first onActivityStarted`() {
+ every { any().getCurrentProcessName() } returns context.packageName
+ every { any().isMainProcess(any()) } returns true
+
+ // 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)
+
+ val manager = checkNotNull(LifecycleManager.instance)
+ val events = mutableListOf>()
+ manager.callbacks = object : LifecycleManager.Callbacks {
+ override fun onTrackVisitReady(source: String?, requestUrl: String?) {
+ events.add(source to requestUrl)
+ }
+ }
+
+ // 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)
+
+ 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
+ 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)
+ }
+}