Coverage Summary for Class: LifecycleManager (cloud.mindbox.mobile_sdk.managers)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| LifecycleManager |
87.5%
(21/24)
|
88%
(44/50)
|
94.7%
(54/57)
|
98.1%
(309/315)
|
| LifecycleManager$Callbacks |
|
| LifecycleManager$Callbacks$DefaultImpls |
80%
(4/5)
|
|
80%
(4/5)
|
0%
(0/1)
|
| LifecycleManager$cancelKeepaliveTimer$1 |
100%
(1/1)
|
100%
(2/2)
|
100%
(2/2)
|
100%
(12/12)
|
| LifecycleManager$Companion |
100%
(3/3)
|
50%
(13/26)
|
90.9%
(20/22)
|
81.2%
(104/128)
|
| LifecycleManager$dispatchCurrentVisit$1 |
100%
(1/1)
|
75%
(6/8)
|
100%
(6/6)
|
91.5%
(43/47)
|
| LifecycleManager$isNewHash$1 |
100%
(1/1)
|
100%
(4/4)
|
100%
(4/4)
|
100%
(32/32)
|
| LifecycleManager$onActivityStarted$1 |
100%
(1/1)
|
85.7%
(12/14)
|
100%
(14/14)
|
97.1%
(100/103)
|
| LifecycleManager$onMovedToBackground$1 |
100%
(1/1)
|
|
100%
(5/5)
|
100%
(19/19)
|
| LifecycleManager$onMovedToForeground$1 |
100%
(1/1)
|
100%
(4/4)
|
100%
(8/8)
|
100%
(34/34)
|
| LifecycleManager$sendTrackVisit$1 |
100%
(1/1)
|
85.7%
(12/14)
|
100%
(11/11)
|
95.6%
(65/68)
|
| LifecycleManager$startKeepaliveTimer$1 |
50%
(1/2)
|
0%
(0/2)
|
80%
(4/5)
|
100%
(34/34)
|
| LifecycleManager$startKeepaliveTimer$1$invoke$$inlined$timer$default$1 |
0%
(0/2)
|
|
| LifecycleManager$updateActivityState$1 |
100%
(1/1)
|
|
100%
(2/2)
|
100%
(13/13)
|
| LifecycleManager$WhenMappings |
|
| Total |
84.1%
(37/44)
|
78.2%
(97/124)
|
95%
(134/141)
|
94.9%
(765/806)
|
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
import cloud.mindbox.mobile_sdk.models.PUSH
import cloud.mindbox.mobile_sdk.pushes.PushNotificationManager.IS_OPENED_FROM_PUSH_BUNDLE_KEY
import cloud.mindbox.mobile_sdk.utils.loggingRunCatching
import java.util.Timer
import kotlin.concurrent.timer
internal class LifecycleManager internal constructor(
private var currentActivityName: String?,
private var currentIntent: Intent?,
private var isAppInBackground: Boolean,
) : 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 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
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
}
}
}
}
/**
* 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<Int>()
private var skipNextTrackVisit = 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?) = 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}")
callbacks?.onActivityStarted(activity)
val sameActivity = currentActivityName == activity.javaClass.name
val intent = activity.intent
intentChanged = if (currentIntent != intent) {
updateActivityState(activity)
intent?.hashCode()?.let(::isNewHash) ?: true
} else {
false
}
if (isAppInBackground || !intentChanged) {
isAppInBackground = false
if (foregroundedWithoutIntent && intentChanged) {
foregroundedWithoutIntent = false
sendTrackVisit(intent)
}
return@loggingRunCatching
}
sendTrackVisit(intent, sameActivity)
}
override fun onActivityResumed(activity: Activity) {
mindboxLogI("onActivityResumed. activity: ${activity.javaClass.simpleName}")
isCurrentActivityResumed = true
callbacks?.onActivityResumed(activity)
}
override fun onActivityPaused(activity: Activity) {
mindboxLogI("onActivityPaused. activity: ${activity.javaClass.simpleName}")
isCurrentActivityResumed = false
callbacks?.onActivityPaused(activity)
}
override fun onActivityStopped(activity: Activity) {
mindboxLogI("onActivityStopped. activity: ${activity.javaClass.simpleName}")
if (currentIntent == null || currentActivityName == null) {
updateActivityState(activity)
}
callbacks?.onActivityStopped(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 (isNewHash(intent.hashCode())) {
sendTrackVisit(intent)
}
}
return currentIntent != null
}
/**
* 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?) {
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 onMovedToBackground(): Unit = loggingRunCatching {
mindboxLogI("onAppMovedToBackground")
isAppInBackground = true
pendingVisit = false
foregroundedWithoutIntent = false
cancelKeepaliveTimer()
}
private fun onMovedToForeground(): Unit = loggingRunCatching {
mindboxLogI("onAppMovedToForeground")
if (skipNextTrackVisit) {
skipNextTrackVisit = false
return@loggingRunCatching
}
val intent = currentIntent
if (intent != null) {
sendTrackVisit(intent)
} else {
foregroundedWithoutIntent = true
mindboxLogI("Track visit deferred — foregrounded before first activity")
}
}
private fun updateActivityState(activity: Activity): Unit = loggingRunCatching {
currentActivityName = activity.javaClass.name
currentIntent = activity.intent
}
private fun sendTrackVisit(
intent: Intent?,
sameActivity: Boolean = true,
): Unit = loggingRunCatching {
val source = if (intentChanged) intentSource(intent) else DIRECT
if (!sameActivity && source == DIRECT) return@loggingRunCatching
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")
}
/**
* 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 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()
keepaliveTimer = timer(
initialDelay = TIMER_PERIOD,
period = TIMER_PERIOD,
action = { callbacks?.onTrackVisitReady(null, null) },
)
}
private fun cancelKeepaliveTimer(): Unit = loggingRunCatching {
keepaliveTimer?.cancel()
keepaliveTimer = null
}
}