diff --git a/build.gradle b/build.gradle index 55faa13a..1159082e 100644 --- a/build.gradle +++ b/build.gradle @@ -22,19 +22,6 @@ allprojects { } } -apply plugin: 'org.jetbrains.kotlinx.kover' - -dependencies { - kover(project(':sdk')) - kover(project(':mindbox-firebase')) - kover(project(':mindbox-huawei')) - kover(project(':mindbox-rustore')) - kover(project(':mindbox-firebase-starter')) - kover(project(':mindbox-huawei-starter')) - kover(project(':mindbox-rustore-starter')) - kover(project(':mindbox-sdk-starter-core')) -} - tasks.register('clean', Delete) { delete rootProject.getLayout().getBuildDirectory() } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 926a903d..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" @@ -39,7 +40,6 @@ agcp = "1.9.1.300" ktlint-plugin = "12.1.1" ksp = "1.9.22-1.0.17" maven_publish = "0.32.0" -kover = "0.8.3" pushclient = "7.2.0" @@ -52,7 +52,6 @@ buildscript-plugins = [ "ktlint_gradle_plugin", "ksp_gradle_plugin", "maven_publish_plugin", - "kover_gradle_plugin", ] test = [ @@ -88,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" } @@ -118,5 +118,4 @@ google_services = { module = "com.google.gms:google-services", version.ref = "go agcp = { module = "com.huawei.agconnect:agcp", version.ref = "agcp" } ktlint_gradle_plugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint-plugin" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } -maven_publish_plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven_publish" } -kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } \ No newline at end of file +maven_publish_plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven_publish" } \ No newline at end of file diff --git a/modulesCommon.gradle b/modulesCommon.gradle index e1b436a4..3d607614 100644 --- a/modulesCommon.gradle +++ b/modulesCommon.gradle @@ -3,7 +3,6 @@ apply plugin: 'kotlin-android' apply plugin: 'signing' apply plugin: 'org.jlleitschuh.gradle.ktlint' apply plugin: 'com.vanniktech.maven.publish' -apply plugin: 'org.jetbrains.kotlinx.kover' group = 'com.github.mindbox-cloud' diff --git a/sdk/build.gradle b/sdk/build.gradle index 9d7d78b7..79789515 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -87,6 +87,7 @@ dependencies { // Handle app lifecycle implementation libs.androidx.lifecycle + implementation libs.androidx.startup implementation libs.threetenabp // Glide diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml index e50bafe2..92ba798a 100644 --- a/sdk/src/main/AndroidManifest.xml +++ b/sdk/src/main/AndroidManifest.xml @@ -9,6 +9,16 @@ + + + + Unit>() private val deviceUuidCallbacks = ConcurrentHashMap Unit>() - private lateinit var lifecycleManager: LifecycleManager + private val lifecycleManager: LifecycleManager? get() = LifecycleManager.instance private val userVisitManager: UserVisitManager by mindboxInject { userVisitManager } private val timeProvider by mindboxInject { timeProvider } @@ -554,152 +552,139 @@ public object Mindbox : MindboxLog { context: Context, configuration: MindboxConfiguration, pushServices: List, - ) { - LoggingExceptionHandler.runCatching { - verifyThreadExecution(methodName = "init") - val currentProcessName = context.getCurrentProcessName() - if (!context.isMainProcess(currentProcessName)) { - logW("Skip Mindbox init not in main process! Current process $currentProcessName") - return@runCatching - } - Stopwatch.start(Stopwatch.INIT_SDK) + ): Unit = loggingRunCatching { + verifyThreadExecution(methodName = "init") + val currentProcessName = context.getCurrentProcessName() + if (!context.isMainProcess(currentProcessName)) { + logW("Skip Mindbox init not in main process! Current process $currentProcessName") + return@loggingRunCatching + } + Stopwatch.start(Stopwatch.INIT_SDK) - initComponents(context.applicationContext) - pushConverters = selectPushServiceHandler(pushServices) - logI("init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + + initComponents(context.applicationContext) + pushConverters = selectPushServiceHandler(pushServices) + logI( + "init in $currentProcessName. firstInitCall: ${firstInitCall.get()}, " + "configuration: $configuration, pushServices: " + pushServices.joinToString(", ") { it.javaClass.simpleName } + - ", SdkVersion:${getSdkVersion()}, CommonSdkVersion:${MindboxCommon.VERSION_NAME}") + ", SdkVersion:${getSdkVersion()}, CommonSdkVersion:${MindboxCommon.VERSION_NAME}", + ) - if (!firstInitCall.get()) { - InitializeLock.reset(InitializeLock.State.SAVE_MINDBOX_CONFIG) - } else { - userVisitManager.saveUserVisit() - } + if (!firstInitCall.get()) { + InitializeLock.reset(InitializeLock.State.SAVE_MINDBOX_CONFIG) + } else { + userVisitManager.saveUserVisit() + } - initScope.launch { - InitializeLock.await(InitializeLock.State.MIGRATION) - val checkResult = checkConfig(configuration) - val validatedConfiguration = validateConfiguration(configuration) - DbManager.saveConfigurations(Configuration(configuration)) - logI("init. checkResult: $checkResult") - if (checkResult != ConfigUpdate.NOT_UPDATED && !MindboxPreferences.isFirstInitialize) { - logI("init. softReinitialization") - softReinitialization(context.applicationContext) - } + launchInitJob(context, configuration, pushServices) + setupLifecycleManager(context) + attachLifecycleCallbacks() + } - if (checkResult == ConfigUpdate.UPDATED) { - setPushServiceHandler(context, pushServices) - firstInitialization(context.applicationContext, validatedConfiguration) + private fun launchInitJob( + context: Context, + configuration: MindboxConfiguration, + pushServices: List, + ) { + initScope.launch { + InitializeLock.await(InitializeLock.State.MIGRATION) + val checkResult = checkConfig(configuration) + val validatedConfiguration = validateConfiguration(configuration) + DbManager.saveConfigurations(Configuration(configuration)) + logI("init. checkResult: $checkResult") + if (checkResult != ConfigUpdate.NOT_UPDATED && !MindboxPreferences.isFirstInitialize) { + logI("init. softReinitialization") + softReinitialization(context.applicationContext) + } - val isTrackVisitNotSent = Mindbox::lifecycleManager.isInitialized && - !lifecycleManager.isTrackVisitSent() - if (isTrackVisitNotSent) { - MindboxLoggerImpl.d(this, "Track visit event with source $DIRECT") - sendTrackVisitEvent(context.applicationContext, DIRECT) - } - } else { - mindboxScope.launch { - setPushServiceHandler(context, pushServices) - } - MindboxEventManager.sendEventsIfExist(context.applicationContext) + if (checkResult == ConfigUpdate.UPDATED) { + setPushServiceHandler(context, pushServices) + firstInitialization(context.applicationContext, validatedConfiguration) + } else { + mindboxScope.launch { + setPushServiceHandler(context, pushServices) } - MindboxPreferences.uuidDebugEnabled = configuration.uuidDebugEnabled - }.initState(InitializeLock.State.SAVE_MINDBOX_CONFIG) - .invokeOnCompletion { throwable -> - if (throwable == null) { - if (firstInitCall.get()) { - val activity = context as? Activity - if (activity != null && lifecycleManager.isCurrentActivityResumed) { - inAppMessageManager.registerCurrentActivity(activity) - mindboxScope.launch { - inAppMutex.withLock { - logI("Start inapp manager after init. firstInitCall: ${firstInitCall.get()}") - if (!firstInitCall.getAndSet(false)) return@launch - inAppMessageManager.listenEventAndInApp() - inAppMessageManager.initLogs() - MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) - inAppMessageManager.requestConfig().join() - } - } + MindboxEventManager.sendEventsIfExist(context.applicationContext) + } + MindboxPreferences.uuidDebugEnabled = configuration.uuidDebugEnabled + }.initState(InitializeLock.State.SAVE_MINDBOX_CONFIG) + .invokeOnCompletion { throwable -> + if (throwable == null && firstInitCall.get()) { + val activity = context as? Activity + if (activity != null && lifecycleManager?.isCurrentActivityResumed == true) { + inAppMessageManager.registerCurrentActivity(activity) + mindboxScope.launch { + inAppMutex.withLock { + logI("Start inapp manager after init. firstInitCall: ${firstInitCall.get()}") + if (!firstInitCall.getAndSet(false)) return@launch + inAppMessageManager.listenEventAndInApp() + inAppMessageManager.initLogs() + MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) + inAppMessageManager.requestConfig().join() } } } } - // Handle back app in foreground - (context.applicationContext as? Application)?.apply { - val applicationLifecycle = ProcessLifecycleOwner.get().lifecycle + } + } - if (!Mindbox::lifecycleManager.isInitialized) { - val activity = context as? Activity - val isApplicationResumed = applicationLifecycle.currentState == RESUMED - if (isApplicationResumed && activity == null) { - logE("Incorrect context type for calling init in this place") - } - if (isApplicationResumed || context !is Application) { - logW( - "We recommend to call Mindbox.init() synchronously from " + - "Application.onCreate. If you can't do so, don't forget to " + - "call Mindbox.initPushServices from Application.onCreate", - ) + private fun setupLifecycleManager(context: Context) { + if (LifecycleManager.isRegister) { + if (!firstInitCall.get()) { + lifecycleManager?.scheduleReinitTrackVisit() + } + return + } + + logW("Register LifecycleManager (startup initializer not found)") + LifecycleManager.register(context) + } + + private fun attachLifecycleCallbacks() { + lifecycleManager?.callbacks = object : LifecycleManager.Callbacks { + override fun onActivityStarted(activity: Activity) { + UuidCopyManager.onAppMovedToForeground(activity) + mindboxScope.launch { + if (!MindboxPreferences.isFirstInitialize) { + updateAppInfo(activity.applicationContext) } + } + } - logI("init. init lifecycleManager") - lifecycleManager = LifecycleManager( - currentActivityName = activity?.javaClass?.name, - currentIntent = activity?.intent, - isAppInBackground = !isApplicationResumed, - onActivityStarted = { startedActivity -> - UuidCopyManager.onAppMovedToForeground(startedActivity) - mindboxScope.launch { - if (!MindboxPreferences.isFirstInitialize) { - updateAppInfo(startedActivity.applicationContext) - } - } - }, - onActivityPaused = { pausedActivity -> - inAppMessageManager.onPauseCurrentActivity(pausedActivity) - }, - onActivityResumed = { resumedActivity -> - inAppMessageManager.onResumeCurrentActivity( - resumedActivity - ) - if (firstInitCall.get()) { - mindboxScope.launch { - InitializeLock.await(InitializeLock.State.SAVE_MINDBOX_CONFIG) - inAppMutex.withLock { - logI("Start inapp manager after resume activity. firstInitCall: ${firstInitCall.get()}") - if (!firstInitCall.getAndSet(false)) return@launch - inAppMessageManager.listenEventAndInApp() - inAppMessageManager.initLogs() - MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) - inAppMessageManager.requestConfig().join() - } - } - } - }, - onActivityStopped = { resumedActivity -> - inAppMessageManager.onStopCurrentActivity(resumedActivity) - }, - onTrackVisitReady = { source, requestUrl -> - sessionStorageManager.hasSessionExpired() - eventScope.launch { - sendTrackVisitEvent( - MindboxDI.appModule.appContext, - source, - requestUrl - ) - } + override fun onActivityPaused(activity: Activity) { + inAppMessageManager.onPauseCurrentActivity(activity) + } + + override fun onActivityResumed(activity: Activity) { + inAppMessageManager.onResumeCurrentActivity(activity) + if (firstInitCall.get()) { + mindboxScope.launch { + InitializeLock.await(InitializeLock.State.SAVE_MINDBOX_CONFIG) + inAppMutex.withLock { + logI("Start in-app manager after resume activity. firstInitCall: ${firstInitCall.get()}") + if (!firstInitCall.getAndSet(false)) return@launch + inAppMessageManager.listenEventAndInApp() + inAppMessageManager.initLogs() + MindboxEventManager.eventFlow.emit(MindboxEventManager.appStarted()) + inAppMessageManager.requestConfig().join() } - ) - } else { - unregisterActivityLifecycleCallbacks(lifecycleManager) - applicationLifecycle.removeObserver(lifecycleManager) - lifecycleManager.wasReinitialized() + } } + } - registerActivityLifecycleCallbacks(lifecycleManager) - applicationLifecycle.addObserver(lifecycleManager) + override fun onActivityStopped(activity: Activity) { + inAppMessageManager.onStopCurrentActivity(activity) + } + + override fun onTrackVisitReady(source: String?, requestUrl: String?) { + sessionStorageManager.hasSessionExpired() + eventScope.launch { + sendTrackVisitEvent( + MindboxDI.appModule.appContext, + source, + requestUrl, + ) + } } } } @@ -889,8 +874,8 @@ public object Mindbox : MindboxLog { */ public fun onNewIntent(intent: Intent?): Unit = LoggingExceptionHandler.runCatching { MindboxLoggerImpl.d(this, "onNewIntent. intent: $intent") - if (Mindbox::lifecycleManager.isInitialized) { - lifecycleManager.onNewIntent(intent) + if (lifecycleManager != null) { + lifecycleManager?.onNewIntent(intent) } else { MindboxLoggerImpl.d(this, "onNewIntent. LifecycleManager is not initialized. Skipping.") } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 76c5ccca..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() } } } } @@ -782,12 +782,10 @@ internal class WebViewInAppViewHolder( } private data class NavigationInterceptedPayload( - @SerializedName("url") val url: String ) private data class ErrorPayload( - @SerializedName("error") val error: String ) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt index 858cb1e4..df8dc7b1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/LifecycleManager.kt @@ -2,11 +2,16 @@ package cloud.mindbox.mobile_sdk.managers import android.app.Activity import android.app.Application +import android.content.Context import android.content.Intent import android.os.Bundle import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import cloud.mindbox.mobile_sdk.Mindbox.logE +import cloud.mindbox.mobile_sdk.Mindbox.logW +import cloud.mindbox.mobile_sdk.logger.MindboxLog import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.models.DIRECT import cloud.mindbox.mobile_sdk.models.LINK @@ -16,186 +21,283 @@ import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import java.util.Timer import kotlin.concurrent.timer -internal class LifecycleManager( +internal class LifecycleManager internal constructor( private var currentActivityName: String?, private var currentIntent: Intent?, private var isAppInBackground: Boolean, - private var onActivityResumed: (resumedActivity: Activity) -> Unit, - private var onActivityPaused: (pausedActivity: Activity) -> Unit, - private var onActivityStarted: (activity: Activity) -> Unit, - private var onActivityStopped: (activity: Activity) -> Unit, - private var onTrackVisitReady: (source: String?, requestUrl: String?) -> Unit, -) : Application.ActivityLifecycleCallbacks, LifecycleEventObserver { +) : Application.ActivityLifecycleCallbacks, LifecycleEventObserver, MindboxLog { + + internal interface Callbacks { + fun onActivityStarted(activity: Activity) {} + + fun onActivityPaused(activity: Activity) {} + + fun onActivityResumed(activity: Activity) {} + + fun onActivityStopped(activity: Activity) {} + + fun onTrackVisitReady(source: String?, requestUrl: String?) {} + } companion object { - private const val SCHEMA_HTTP = "http" - private const val SCHEMA_HTTPS = "https" + private const val TIMER_PERIOD = 1_200_000L + private const val MAX_INTENT_HASHES = 50 + + @Volatile + internal var instance: LifecycleManager? = null - private const val TIMER_PERIOD = 1200000L - private const val MAX_INTENT_HASHES_SIZE = 50 + internal val isRegister: Boolean get() = instance != null + + internal fun register(context: Context) { + val lifecycle = ProcessLifecycleOwner.get().lifecycle + val activity = context as? Activity + val application = context.applicationContext as? Application + val isForegrounded = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + + if (isForegrounded && activity == null) { + logE("Incorrect context type for calling init in this place") + } + if (isForegrounded || context !is Application) { + logW( + "We recommend to call Mindbox.init() synchronously from " + + "Application.onCreate. If you can't do so, don't forget to " + + "call Mindbox.initPushServices from Application.onCreate", + ) + } + + LifecycleManager( + currentActivityName = activity?.javaClass?.name, + currentIntent = activity?.intent, + isAppInBackground = !isForegrounded, + ).also { manager -> + application?.registerActivityLifecycleCallbacks(manager) + lifecycle.addObserver(manager) + instance = manager + } + } } - private var isIntentChanged = true - private var timer: Timer? = null + /** + * True when a foreground transition happened before [callbacks] was set — + * i.e. before [cloud.mindbox.mobile_sdk.Mindbox.init] was called. + */ + @Volatile + private var pendingVisit: Boolean = false + + @Volatile + var callbacks: Callbacks? = null + set(value) { + field = value + if (value != null && pendingVisit) { + pendingVisit = false + dispatchCurrentVisit(value) + } + } + + /** + * True by default — Activity.onResume() fires before the manager is registered + * when Mindbox.init() is called from Activity.onCreate(). + */ + var isCurrentActivityResumed: Boolean = true + private set + + private var intentChanged = true + private var keepaliveTimer: Timer? = null private val intentHashes = mutableListOf() + private var skipNextTrackVisit = false /** - * True by default. - * Has to be true because Activity.onResume() triggers before Lifecycle Manager is registered - * when Mindbox.init() was called in Activity.onCreate() - **/ - var isCurrentActivityResumed = true - private var skipSendingTrackVisit = false + * True when [onMovedToForeground] was called while [currentIntent] was still null — + * i.e. the app foregrounded before the first [onActivityStarted] callback arrived. + * + * This happens in Case 3: no [MindboxLifecycleInitializer], [Mindbox.init] called from + * [Application.onCreate]. [ProcessLifecycleOwnerInitializer] registers [LifecycleDispatcher] + * first, so the process-level ON_START fires *before* [LifecycleManager.onActivityStarted] + * updates [currentIntent]. The flag is cleared and the visit is dispatched inside + * [onActivityStarted] once the intent becomes available. + */ + private var foregroundedWithoutIntent = false - override fun onActivityCreated(activity: Activity, p1: Bundle?) { - } + override fun onActivityCreated(activity: Activity, p1: Bundle?) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) = Unit + + override fun onActivityDestroyed(activity: Activity) = Unit override fun onActivityStarted(activity: Activity): Unit = loggingRunCatching { mindboxLogI("onActivityStarted. activity: ${activity.javaClass.simpleName}") - onActivityStarted.invoke(activity) - val areActivitiesEqual = currentActivityName == activity.javaClass.name + callbacks?.onActivityStarted(activity) + + val sameActivity = currentActivityName == activity.javaClass.name val intent = activity.intent - isIntentChanged = if (currentIntent != intent) { - updateActivityParameters(activity) - intent?.hashCode()?.let(::updateHashesList) ?: true + intentChanged = if (currentIntent != intent) { + updateActivityState(activity) + intent?.hashCode()?.let(::isNewHash) ?: true } else { false } - if (isAppInBackground || !isIntentChanged) { + if (isAppInBackground || !intentChanged) { isAppInBackground = false + if (foregroundedWithoutIntent && intentChanged) { + foregroundedWithoutIntent = false + sendTrackVisit(intent ?: return@loggingRunCatching) + } return@loggingRunCatching } - sendTrackVisit(activity.intent, areActivitiesEqual) + sendTrackVisit(intent ?: return@loggingRunCatching, sameActivity) } override fun onActivityResumed(activity: Activity) { mindboxLogI("onActivityResumed. activity: ${activity.javaClass.simpleName}") isCurrentActivityResumed = true - onActivityResumed.invoke(activity) - isCurrentActivityResumed = true + callbacks?.onActivityResumed(activity) } override fun onActivityPaused(activity: Activity) { mindboxLogI("onActivityPaused. activity: ${activity.javaClass.simpleName}") isCurrentActivityResumed = false - onActivityPaused.invoke(activity) - isCurrentActivityResumed = false + callbacks?.onActivityPaused(activity) } override fun onActivityStopped(activity: Activity) { mindboxLogI("onActivityStopped. activity: ${activity.javaClass.simpleName}") if (currentIntent == null || currentActivityName == null) { - updateActivityParameters(activity) + updateActivityState(activity) } - onActivityStopped.invoke(activity) + callbacks?.onActivityStopped(activity) } - override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) { - } - - override fun onActivityDestroyed(activity: Activity) { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_STOP -> onMovedToBackground() + Lifecycle.Event.ON_START -> onMovedToForeground() + else -> Unit + } } fun isTrackVisitSent(): Boolean { currentIntent?.let { intent -> - if (updateHashesList(intent.hashCode())) { + if (isNewHash(intent.hashCode())) { sendTrackVisit(intent) } } return currentIntent != null } - fun wasReinitialized() { - skipSendingTrackVisit = true + /** + * Schedules a track-visit to be dispatched the next time [callbacks] is assigned. + * + * Call this before replacing [callbacks] via [cloud.mindbox.mobile_sdk.Mindbox.init] + * so the new endpoint receives a track-visit immediately upon reinitialisation. + * The backend uses this signal to learn the device is now active in the new environment. + */ + fun scheduleReinitTrackVisit() { + pendingVisit = true + mindboxLogI("Track visit scheduled for reinit") } - fun onNewIntent(newIntent: Intent?): Unit? = newIntent?.let { intent -> - if (intent.data != null || intent.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true) { - isIntentChanged = updateHashesList(intent.hashCode()) - sendTrackVisit(intent) - skipSendingTrackVisit = isAppInBackground - } + fun onNewIntent(newIntent: Intent?) { + val intent = newIntent ?: return + val hasDeepLink = intent.data != null + val isFromPush = intent.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true + if (!hasDeepLink && !isFromPush) return + + intentChanged = isNewHash(intent.hashCode()) + sendTrackVisit(intent) + skipNextTrackVisit = isAppInBackground } - private fun onAppMovedToBackground(): Unit = loggingRunCatching { + private fun onMovedToBackground(): Unit = loggingRunCatching { mindboxLogI("onAppMovedToBackground") isAppInBackground = true + pendingVisit = false + foregroundedWithoutIntent = false cancelKeepaliveTimer() } - private fun onAppMovedToForeground(): Unit = loggingRunCatching { + private fun onMovedToForeground(): Unit = loggingRunCatching { mindboxLogI("onAppMovedToForeground") - if (!skipSendingTrackVisit) { - currentIntent?.let(::sendTrackVisit) + if (skipNextTrackVisit) { + skipNextTrackVisit = false + return@loggingRunCatching + } + val intent = currentIntent + if (intent != null) { + sendTrackVisit(intent) } else { - skipSendingTrackVisit = false + foregroundedWithoutIntent = true + mindboxLogI("Track visit deferred — foregrounded before first activity") } } - private fun updateActivityParameters(activity: Activity): Unit = loggingRunCatching { + private fun updateActivityState(activity: Activity): Unit = loggingRunCatching { currentActivityName = activity.javaClass.name currentIntent = activity.intent } private fun sendTrackVisit( intent: Intent, - areActivitiesEqual: Boolean = true, + sameActivity: Boolean = true, ): Unit = loggingRunCatching { - val source = if (isIntentChanged) source(intent) else DIRECT - - if (areActivitiesEqual || source != DIRECT) { - val requestUrl = if (source == LINK) intent.data?.toString() else null - onTrackVisitReady.invoke(source, requestUrl) - startKeepaliveTimer() + val source = if (intentChanged) intentSource(intent) else DIRECT + if (!sameActivity && source == DIRECT) return@loggingRunCatching - mindboxLogI("Track visit event with source $source and url $requestUrl") + val cb = callbacks + if (cb == null) { + pendingVisit = true + mindboxLogI("Track visit pending (no callbacks yet)") + return@loggingRunCatching } + pendingVisit = false + val requestUrl = if (source == LINK) intent.data?.toString() else null + cb.onTrackVisitReady(source, requestUrl) + startKeepaliveTimer() + mindboxLogI("Track visit event with source $source and url $requestUrl") } - private fun source(intent: Intent?): String? = loggingRunCatching(defaultValue = null) { - when { - intent?.scheme == SCHEMA_HTTP || intent?.scheme == SCHEMA_HTTPS -> LINK - intent?.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true -> PUSH - else -> DIRECT - } + /** + * Derives source and URL from the already-stored [currentIntent]/[intentChanged] and + * dispatches the track-visit through [cb]. + * + * Called from the [callbacks] setter when [pendingVisit] is raised — the same pattern + * iOS uses in `MBSessionManager` when `initializationCompleted` fires while `isActive` is true. + */ + private fun dispatchCurrentVisit(cb: Callbacks): Unit = loggingRunCatching { + val intent = currentIntent ?: return@loggingRunCatching + val source = if (intentChanged) intentSource(intent) else DIRECT + val requestUrl = if (source == LINK) intent.data?.toString() else null + cb.onTrackVisitReady(source, requestUrl) + startKeepaliveTimer() + mindboxLogI("Track visit dispatched from pending state: source=$source url=$requestUrl") } - private fun updateHashesList(code: Int): Boolean = loggingRunCatching(defaultValue = true) { - if (!intentHashes.contains(code)) { - if (intentHashes.size >= MAX_INTENT_HASHES_SIZE) { - intentHashes.removeAt(0) - } - intentHashes.add(code) - true - } else { - false - } + private fun intentSource(intent: Intent): String = when { + intent.scheme == "http" || intent.scheme == "https" -> LINK + intent.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true -> PUSH + else -> DIRECT + } + + private fun isNewHash(hash: Int): Boolean = loggingRunCatching(defaultValue = true) { + if (intentHashes.contains(hash)) return@loggingRunCatching false + if (intentHashes.size >= MAX_INTENT_HASHES) intentHashes.removeAt(0) + intentHashes.add(hash) + true } private fun startKeepaliveTimer(): Unit = loggingRunCatching { cancelKeepaliveTimer() - timer = timer( + keepaliveTimer = timer( initialDelay = TIMER_PERIOD, period = TIMER_PERIOD, - action = { onTrackVisitReady.invoke(null, null) }, + action = { callbacks?.onTrackVisitReady(null, null) }, ) } private fun cancelKeepaliveTimer(): Unit = loggingRunCatching { - timer?.cancel() - timer = null - } - - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - when (event) { - Lifecycle.Event.ON_STOP -> onAppMovedToBackground() - Lifecycle.Event.ON_START -> onAppMovedToForeground() - else -> { - // do nothing - } - } + keepaliveTimer?.cancel() + keepaliveTimer = null } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt new file mode 100644 index 00000000..379f44ab --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxLifecycleInitializer.kt @@ -0,0 +1,31 @@ +package cloud.mindbox.mobile_sdk.managers + +import android.content.Context +import android.util.Log +import androidx.annotation.RestrictTo +import androidx.startup.Initializer +import cloud.mindbox.mobile_sdk.getCurrentProcessName +import cloud.mindbox.mobile_sdk.isMainProcess +import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl + +/** + * Registers [LifecycleManager] at application startup via androidx.startup so that lifecycle + * tracking begins before [cloud.mindbox.mobile_sdk.Mindbox.init] is called. + * + * Track-visit events are only dispatched after [cloud.mindbox.mobile_sdk.Mindbox.init] wires + * the [LifecycleManager.onTrackVisitReady] callback. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY) +public class MindboxLifecycleInitializer : Initializer { + + override fun create(context: Context) { + val currentProcessName = context.getCurrentProcessName() + if (!context.isMainProcess(currentProcessName)) return + + // Log before init mindbox + Log.i(MindboxLoggerImpl.TAG, "LifecycleInitializer: Register LifecycleManager in startup initializer") + LifecycleManager.register(context) + } + + override fun dependencies(): List>> = emptyList() +}