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 @@ + + + + , - ) { - 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() + } + + launchInitJob(context, configuration, pushServices) + setupLifecycleManager(context) + attachLifecycleCallbacks() + } + + 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) } - 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) - } + if (checkResult == ConfigUpdate.UPDATED) { + setPushServiceHandler(context, pushServices) + firstInitialization(context.applicationContext, validatedConfiguration) - if (checkResult == ConfigUpdate.UPDATED) { + 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) - firstInitialization(context.applicationContext, validatedConfiguration) - - 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) } - 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) { + 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 (!Mindbox::lifecycleManager.isInitialized) { + val applicationLifecycle = ProcessLifecycleOwner.get().lifecycle + 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", + ) + } - 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 - ) - } - } - ) - } else { - unregisterActivityLifecycleCallbacks(lifecycleManager) - applicationLifecycle.removeObserver(lifecycleManager) - lifecycleManager.wasReinitialized() + val existingManager = LifecycleManager.instance + lifecycleManager = if (existingManager != null) { + logI("init. attaching callbacks to existing lifecycleManager") + existingManager + } else { + logI("init. creating lifecycleManager (startup initializer not found)") + LifecycleManager( + currentActivityName = activity?.javaClass?.name, + currentIntent = activity?.intent, + isAppInBackground = !isApplicationResumed, + ).also { manager -> + (context.applicationContext as? Application)?.apply { + registerActivityLifecycleCallbacks(manager) + applicationLifecycle.addObserver(manager) + } } + } + } else { + lifecycleManager.wasReinitialized() + } + } - registerActivityLifecycleCallbacks(lifecycleManager) - applicationLifecycle.addObserver(lifecycleManager) + private fun attachLifecycleCallbacks() { + lifecycleManager.onActivityStarted = { startedActivity -> + UuidCopyManager.onAppMovedToForeground(startedActivity) + mindboxScope.launch { + if (!MindboxPreferences.isFirstInitialize) { + updateAppInfo(startedActivity.applicationContext) + } + } + } + lifecycleManager.onActivityPaused = { pausedActivity -> + inAppMessageManager.onPauseCurrentActivity(pausedActivity) + } + lifecycleManager.onActivityResumed = { resumedActivity -> + inAppMessageManager.onResumeCurrentActivity(resumedActivity) + 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() + } + } + } + } + lifecycleManager.onActivityStopped = { stoppedActivity -> + inAppMessageManager.onStopCurrentActivity(stoppedActivity) + } + lifecycleManager.onTrackVisitReady = { source, requestUrl -> + sessionStorageManager.hasSessionExpired() + eventScope.launch { + sendTrackVisitEvent( + MindboxDI.appModule.appContext, + source, + requestUrl, + ) } } } 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..1972a8ff 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 @@ -20,11 +20,6 @@ internal class LifecycleManager( 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 { companion object { @@ -34,8 +29,17 @@ internal class LifecycleManager( private const val TIMER_PERIOD = 1200000L private const val MAX_INTENT_HASHES_SIZE = 50 + + @Volatile + internal var instance: LifecycleManager? = null } + var onActivityResumed: ((resumedActivity: Activity) -> Unit)? = null + var onActivityPaused: ((pausedActivity: Activity) -> Unit)? = null + var onActivityStarted: ((activity: Activity) -> Unit)? = null + var onActivityStopped: ((activity: Activity) -> Unit)? = null + var onTrackVisitReady: ((source: String?, requestUrl: String?) -> Unit)? = null + private var isIntentChanged = true private var timer: Timer? = null private val intentHashes = mutableListOf() @@ -53,7 +57,7 @@ internal class LifecycleManager( override fun onActivityStarted(activity: Activity): Unit = loggingRunCatching { mindboxLogI("onActivityStarted. activity: ${activity.javaClass.simpleName}") - onActivityStarted.invoke(activity) + onActivityStarted?.invoke(activity) val areActivitiesEqual = currentActivityName == activity.javaClass.name val intent = activity.intent isIntentChanged = if (currentIntent != intent) { @@ -74,14 +78,14 @@ internal class LifecycleManager( override fun onActivityResumed(activity: Activity) { mindboxLogI("onActivityResumed. activity: ${activity.javaClass.simpleName}") isCurrentActivityResumed = true - onActivityResumed.invoke(activity) + onActivityResumed?.invoke(activity) isCurrentActivityResumed = true } override fun onActivityPaused(activity: Activity) { mindboxLogI("onActivityPaused. activity: ${activity.javaClass.simpleName}") isCurrentActivityResumed = false - onActivityPaused.invoke(activity) + onActivityPaused?.invoke(activity) isCurrentActivityResumed = false } @@ -90,7 +94,7 @@ internal class LifecycleManager( if (currentIntent == null || currentActivityName == null) { updateActivityParameters(activity) } - onActivityStopped.invoke(activity) + onActivityStopped?.invoke(activity) } override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) { @@ -148,7 +152,8 @@ internal class LifecycleManager( if (areActivitiesEqual || source != DIRECT) { val requestUrl = if (source == LINK) intent.data?.toString() else null - onTrackVisitReady.invoke(source, requestUrl) + val callback = onTrackVisitReady ?: return@loggingRunCatching + callback.invoke(source, requestUrl) startKeepaliveTimer() mindboxLogI("Track visit event with source $source and url $requestUrl") @@ -180,7 +185,7 @@ internal class LifecycleManager( timer = timer( initialDelay = TIMER_PERIOD, period = TIMER_PERIOD, - action = { onTrackVisitReady.invoke(null, null) }, + action = { onTrackVisitReady?.invoke(null, 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..0c152b7f --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt @@ -0,0 +1,41 @@ +package cloud.mindbox.mobile_sdk.managers + +import android.app.Application +import android.content.Context +import androidx.annotation.RestrictTo +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.startup.Initializer +import cloud.mindbox.mobile_sdk.getCurrentProcessName +import cloud.mindbox.mobile_sdk.isMainProcess + +/** + * 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 + + val application = context.applicationContext as Application + val isAppInBackground = !ProcessLifecycleOwner.get().lifecycle.currentState + .isAtLeast(Lifecycle.State.STARTED) + + val manager = LifecycleManager( + currentActivityName = null, + currentIntent = null, + isAppInBackground = isAppInBackground, + ) + LifecycleManager.instance = manager + application.registerActivityLifecycleCallbacks(manager) + ProcessLifecycleOwner.get().lifecycle.addObserver(manager) + } + + 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..0a9e6a54 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/LifecycleManagerTest.kt @@ -0,0 +1,317 @@ +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.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class LifecycleManagerTest { + + @get:Rule + val mockkRule = MockKRule(this) + + private val trackVisitEvents = mutableListOf>() + private lateinit var manager: LifecycleManager + private val lifecycleOwner = mockk(relaxed = true) + + @Before + fun setUp() { + manager = LifecycleManager( + currentActivityName = null, + currentIntent = null, + isAppInBackground = true, + ) + trackVisitEvents.clear() + } + + @After + fun tearDown() { + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_STOP) + LifecycleManager.instance = null + } + + // region — null-safety: no crash before init + + @Test + fun `onActivityStarted does not crash when all callbacks are null`() { + manager.onActivityStarted(mockk(relaxed = true)) + } + + @Test + fun `onActivityResumed does not crash when callback is null`() { + manager.onActivityResumed(mockk(relaxed = true)) + } + + @Test + fun `onActivityPaused does not crash when callback is null`() { + manager.onActivityPaused(mockk(relaxed = true)) + } + + @Test + fun `onActivityStopped does not crash when callback is null`() { + manager.onActivityStopped(mockk(relaxed = true)) + } + + @Test + fun `onNewIntent does not crash when callback is null`() { + manager.onNewIntent(Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com"))) + } + + // endregion + + // region — track visit NOT sent before init + + @Test + fun `foreground transition does not send track visit when onTrackVisitReady is null`() { + manager.onActivityStarted(mockk(relaxed = true)) + + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_START) + + assertTrue(trackVisitEvents.isEmpty()) + } + + @Test + fun `onNewIntent does not send track visit when onTrackVisitReady is null`() { + manager.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 onTrackVisitReady is null`() { + val intent = Intent().apply { + putExtra(IS_OPENED_FROM_PUSH_BUNDLE_KEY, true) + } + + manager.onNewIntent(intent) + + assertTrue(trackVisitEvents.isEmpty()) + } + + // endregion + + // region — track visit sent after init + + @Test + fun `foreground sends track visit after onTrackVisitReady is set`() { + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + + manager.onActivityStarted(mockk(relaxed = true)) + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_START) + + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `onNewIntent sends LINK track visit for https deep link after init`() { + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + + 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 init`() { + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + + 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`() { + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + + 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 — wasReinitialized + + @Test + fun `wasReinitialized skips track visit on next foreground`() { + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + manager.onActivityStarted(mockk(relaxed = true)) + + manager.wasReinitialized() + + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_START) + + assertTrue("Track visit must be skipped immediately after wasReinitialized", trackVisitEvents.isEmpty()) + } + + @Test + fun `wasReinitialized resumes normal track visit on the second foreground`() { + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + manager.onActivityStarted(mockk(relaxed = true)) + + manager.wasReinitialized() + + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_START) + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_START) + + assertEquals(1, trackVisitEvents.size) + } + + // endregion + + // region — isTrackVisitSent + + @Test + fun `isTrackVisitSent returns false when currentIntent is null`() { + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + + assertFalse(manager.isTrackVisitSent()) + assertTrue(trackVisitEvents.isEmpty()) + } + + @Test + fun `isTrackVisitSent returns true and sends visit when intent hash is new`() { + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + + // Set currentIntent via onActivityStopped (updates parameters when both are null) + manager.onActivityStopped(mockk(relaxed = true)) + + val result = manager.isTrackVisitSent() + + assertTrue(result) + assertEquals(1, trackVisitEvents.size) + } + + @Test + fun `isTrackVisitSent returns true without extra event when hash already seen`() { + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + + // First call records the hash + manager.onActivityStopped(mockk(relaxed = true)) + manager.isTrackVisitSent() + trackVisitEvents.clear() + + // Second call: hash already in list → no duplicate + manager.isTrackVisitSent() + + assertTrue("Hash already seen — no second track visit expected", trackVisitEvents.isEmpty()) + } + + // endregion + + // region — isCurrentActivityResumed + + @Test + fun `isCurrentActivityResumed is true by default`() { + assertTrue(manager.isCurrentActivityResumed) + } + + @Test + fun `isCurrentActivityResumed is false after onActivityPaused`() { + manager.onActivityPaused(mockk(relaxed = true)) + assertFalse(manager.isCurrentActivityResumed) + } + + @Test + fun `isCurrentActivityResumed is true after onActivityResumed`() { + manager.onActivityPaused(mockk(relaxed = true)) + manager.onActivityResumed(mockk(relaxed = true)) + assertTrue(manager.isCurrentActivityResumed) + } + + // endregion + + // region — callbacks invoked when set + + @Test + fun `all activity callbacks are invoked when assigned`() { + val started = mutableListOf() + val resumed = mutableListOf() + val paused = mutableListOf() + val stopped = mutableListOf() + + manager.onActivityStarted = { started.add(it) } + manager.onActivityResumed = { resumed.add(it) } + manager.onActivityPaused = { paused.add(it) } + manager.onActivityStopped = { stopped.add(it) } + + 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 — background / foreground state + + @Test + fun `ON_STOP sets app to background`() { + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + manager.onActivityStarted(mockk(relaxed = true)) + + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_STOP) + + // After ON_STOP, track visit should not be sent until ON_START + assertTrue(trackVisitEvents.isEmpty()) + } + + @Test + fun `keepalive timer fires onTrackVisitReady`() { + // Verify that onTrackVisitReady is invoked from the timer action + // (tested by calling the timer action lambda directly via sendTrackVisit flow) + // Timer is started after first successful track visit — checked indirectly + manager.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + + manager.onActivityStarted(mockk(relaxed = true)) + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_STOP) + manager.onStateChanged(lifecycleOwner, Lifecycle.Event.ON_START) + + // At least one track visit was sent (which starts the timer) + assertEquals(1, trackVisitEvents.size) + } + + // endregion +} 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..b5669a3f --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializerTest.kt @@ -0,0 +1,126 @@ +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 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) + + // second call overwrites the static field — both instances are valid LifecycleManagers + assertNotNull(LifecycleManager.instance) + // the value from the first call is preserved in firstInstance for reference + assertNotNull(firstInstance) + } + + @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.onTrackVisitReady = { source, url -> trackVisitEvents.add(source to url) } + + 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) + } + } +}