diff --git a/sdk/build.gradle b/sdk/build.gradle index 3b4c906a6..e91771119 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -142,4 +142,7 @@ dependencies { // dex implementation "androidx.multidex:multidex:2.0.1" + // Handle app lifecycle + implementation "androidx.lifecycle:lifecycle-process:2.3.1" + } \ No newline at end of file diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt index 7a99b680c..d44bee451 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Mindbox.kt @@ -1,7 +1,11 @@ package cloud.mindbox.mobile_sdk +import android.app.Activity import android.app.Application import android.content.Context +import android.content.Intent +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.ProcessLifecycleOwner import androidx.annotation.DrawableRes import cloud.mindbox.mobile_sdk.logger.Level import cloud.mindbox.mobile_sdk.logger.MindboxLogger @@ -20,12 +24,18 @@ import java.util.concurrent.TimeUnit object Mindbox { + /** + * Used for determination app open from push + */ + const val IS_OPENED_FROM_PUSH_BUNDLE_KEY = "isOpenedFromPush" + private const val OPERATION_NAME_REGEX = "^[A-Za-z0-9-\\.]{1,249}\$" private val mindboxJob = Job() private val mindboxScope = CoroutineScope(Default + mindboxJob) private val deviceUuidCallbacks = ConcurrentHashMap Unit>() private val fmsTokenCallbacks = ConcurrentHashMap Unit>() + private lateinit var lifecycleManager: LifecycleManager /** * Subscribe to gets token of Firebase Messaging Service used by SDK @@ -58,9 +68,9 @@ object Mindbox { /** * Returns date of FMS token saving */ - fun getFmsTokenSaveDate(): String = - runCatching { return MindboxPreferences.firebaseTokenSaveDate } - .returnOnException { "" } + fun getFmsTokenSaveDate(): String = runCatching { + return MindboxPreferences.firebaseTokenSaveDate + }.returnOnException { "" } /** * Returns SDK version @@ -189,24 +199,67 @@ object Mindbox { mindboxScope.launch { if (MindboxPreferences.isFirstInitialize) { firstInitialization(context, configuration) + val isTrackVisitNotSent = Mindbox::lifecycleManager.isInitialized + && !lifecycleManager.isTrackVisitSent() + if (isTrackVisitNotSent) { + sendTrackVisitEvent(context, DIRECT) + } } else { updateAppInfo(context) MindboxEventManager.sendEventsIfExist(context) } - sendTrackVisitEvent(context, configuration.endpointId) + } - // Handle back app in foreground - val lifecycleManager = LifecycleManager { - runBlocking(Dispatchers.IO) { - sendTrackVisitEvent(context, configuration.endpointId) + // 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) { + MindboxLogger.e( + this@Mindbox, + "Incorrect context type for calling init in this place" + ) } + + lifecycleManager = LifecycleManager( + currentActivityName = activity?.javaClass?.name, + currentIntent = activity?.intent, + isAppInBackground = !isApplicationResumed, + onTrackVisitReady = { source, requestUrl -> + runBlocking(Dispatchers.IO) { + sendTrackVisitEvent(context, source, requestUrl) + } + } + ) + } else { + unregisterActivityLifecycleCallbacks(lifecycleManager) + applicationLifecycle.removeObserver(lifecycleManager) + lifecycleManager.wasReinitialized() } - (context.applicationContext as? Application) - ?.registerActivityLifecycleCallbacks(lifecycleManager) + + registerActivityLifecycleCallbacks(lifecycleManager) + applicationLifecycle.addObserver(lifecycleManager) } }.returnOnException { } } + /** + * Send track visit event after link or push was clicked for [Activity] with launchMode equals + * "singleTop" or "singleTask" or if a client used the [Intent.FLAG_ACTIVITY_SINGLE_TOP] or + * [Intent.FLAG_ACTIVITY_NEW_TASK] + * flag when calling {@link #startActivity}. + * + * @param intent new intent for activity, which was received in [Activity.onNewIntent] method + */ + fun onNewIntent(intent: Intent?) = runCatching { + if (Mindbox::lifecycleManager.isInitialized) { + lifecycleManager.onNewIntent(intent) + } + }.logOnException() + /** * Specifies log level for Mindbox * @@ -367,14 +420,22 @@ object Mindbox { }.logOnException() } - private fun sendTrackVisitEvent(context: Context, endpointId: String) { + private fun sendTrackVisitEvent( + context: Context, + @TrackVisitSource source: String? = null, + requestUrl: String? = null + ) = runCatching { + val applicationContext = context.applicationContext + val endpointId = DbManager.getConfigurations()?.endpointId ?: return val trackVisitData = TrackVisitData( ianaTimeZone = TimeZone.getDefault().id, - endpointId = endpointId + endpointId = endpointId, + source = source, + requestUrl = requestUrl ) - MindboxEventManager.appStarted(context, trackVisitData) - } + MindboxEventManager.appStarted(applicationContext, trackVisitData) + }.logOnException() private fun deliverDeviceUuid(deviceUuid: String) { Executors.newSingleThreadScheduledExecutor().schedule({ @@ -393,4 +454,5 @@ object Mindbox { } }, 1, TimeUnit.SECONDS) } -} \ No newline at end of file + +} 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 5ce3d9641..9fe8a6148 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,24 +2,66 @@ package cloud.mindbox.mobile_sdk.managers import android.app.Activity import android.app.Application +import android.content.Intent import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import cloud.mindbox.mobile_sdk.Mindbox.IS_OPENED_FROM_PUSH_BUNDLE_KEY +import cloud.mindbox.mobile_sdk.logOnException +import cloud.mindbox.mobile_sdk.logger.MindboxLogger +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.returnOnException +import java.util.* +import kotlin.concurrent.timer internal class LifecycleManager( - private val onAppStarted: () -> Unit -) : Application.ActivityLifecycleCallbacks { + private var currentActivityName: String?, + private var currentIntent: Intent?, + private var onTrackVisitReady: (source: String?, requestUrl: String?) -> Unit, + private var isAppInBackground: Boolean +) : Application.ActivityLifecycleCallbacks, LifecycleObserver { - private var currentActivity: Activity? = null + companion object { + + private const val SCHEMA_HTTP = "http" + private const val SCHEMA_HTTPS = "https" + + private const val TIMER_PERIOD = 1200000L + private const val MAX_INTENT_HASHES_SIZE = 50 + + } + + private var isIntentChanged = true + private var timer: Timer? = null + private val intentHashes = mutableListOf() + + private var skipSendingTrackVisit = false override fun onActivityCreated(activity: Activity, p1: Bundle?) { } override fun onActivityStarted(activity: Activity) { - if (currentActivity?.javaClass?.name == activity.javaClass.name) { - onAppStarted() - } else { - currentActivity = activity - } + runCatching { + val areActivitiesEqual = currentActivityName == activity.javaClass.name + val intent = activity.intent + isIntentChanged = if (currentIntent != intent) { + updateActivityParameters(activity) + intent?.hashCode()?.let(::updateHashesList) ?: true + } else { + false + } + + if (isAppInBackground || !isIntentChanged) { + isAppInBackground = false + return + } + + sendTrackVisit(activity.intent, areActivitiesEqual) + }.logOnException() } override fun onActivityResumed(activity: Activity) { @@ -31,8 +73,8 @@ internal class LifecycleManager( } override fun onActivityStopped(activity: Activity) { - if (currentActivity == null) { - currentActivity = activity + if (currentIntent == null || currentActivityName == null) { + updateActivityParameters(activity) } } @@ -44,4 +86,85 @@ internal class LifecycleManager( } + fun isTrackVisitSent(): Boolean { + currentIntent?.let(::sendTrackVisit) + return currentIntent != null + } + + fun wasReinitialized() { + skipSendingTrackVisit = true + } + + fun onNewIntent(newIntent: Intent?) = newIntent?.let { intent -> + if (intent.data != null || intent.extras?.get(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true) { + isIntentChanged = updateHashesList(intent.hashCode()) + sendTrackVisit(intent) + skipSendingTrackVisit = isAppInBackground + } + } + + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + private fun onAppMovedToBackground() { + isAppInBackground = true + cancelKeepAliveTimer() + } + + @OnLifecycleEvent(Lifecycle.Event.ON_START) + private fun onAppMovedToForeground() = if (!skipSendingTrackVisit) { + currentIntent?.let(::sendTrackVisit) + } else { + skipSendingTrackVisit = false + } + + private fun updateActivityParameters(activity: Activity) = runCatching { + currentActivityName = activity.javaClass.name + currentIntent = activity.intent + }.logOnException() + + private fun sendTrackVisit(intent: Intent, areActivitiesEqual: Boolean = true) = runCatching { + 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() + + MindboxLogger.d(this, "Track visit event with source $source and url $requestUrl") + } + }.logOnException() + + private fun source(intent: Intent?) = runCatching { + when { + intent?.scheme == SCHEMA_HTTP || intent?.scheme == SCHEMA_HTTPS -> LINK + intent?.extras?.getBoolean(IS_OPENED_FROM_PUSH_BUNDLE_KEY) == true -> PUSH + else -> DIRECT + } + }.returnOnException { null } + + private fun updateHashesList(code: Int) = runCatching { + if (!intentHashes.contains(code)) { + if (intentHashes.size >= MAX_INTENT_HASHES_SIZE) { + intentHashes.removeAt(0) + } + intentHashes.add(code) + true + } else { + false + } + }.returnOnException { true } + + private fun startKeepAliveTimer() = runCatching { + cancelKeepAliveTimer() + timer = timer( + initialDelay = TIMER_PERIOD, + period = TIMER_PERIOD, + action = { onTrackVisitReady.invoke(null, null) } + ) + }.logOnException() + + private fun cancelKeepAliveTimer() = runCatching { + timer?.cancel() + timer = null + }.logOnException() + } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/PushNotificationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/PushNotificationManager.kt index 641796b4e..786a7fa3c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/PushNotificationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/PushNotificationManager.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.managers import android.app.Notification.DEFAULT_ALL +import android.app.Notification.VISIBILITY_PRIVATE import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -59,6 +60,7 @@ internal object PushNotificationManager { .setPriority(NotificationCompat.PRIORITY_HIGH) .setDefaults(DEFAULT_ALL) .setAutoCancel(true) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .handlePushClick(context, notificationId, uniqueKey) .handleActions(context, notificationId, uniqueKey, pushActions) .handleImageByUrl(data[DATA_IMAGE_URL], title, description) @@ -82,6 +84,7 @@ internal object PushNotificationManager { val importance = NotificationManager.IMPORTANCE_HIGH val channel = NotificationChannel(channelId, channelName, importance).apply { channelDescription.let { description = it } + lockscreenVisibility = VISIBILITY_PRIVATE } notificationManager.createNotificationChannel(channel) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/SharedPreferencesManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/SharedPreferencesManager.kt index dd5b20d94..8061ea2ff 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/SharedPreferencesManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/SharedPreferencesManager.kt @@ -3,6 +3,8 @@ package cloud.mindbox.mobile_sdk.managers import android.app.Application import android.content.Context import android.content.SharedPreferences +import cloud.mindbox.mobile_sdk.logOnException +import cloud.mindbox.mobile_sdk.returnOnException internal object SharedPreferencesManager { @@ -39,7 +41,7 @@ internal object SharedPreferencesManager { fun put( key: String, value: String? - ) = preferences.edit().putString(key, value).apply() + ) = runCatching { preferences.edit().putString(key, value).apply() }.logOnException() /** * Saves [Boolean] into the Preferences. @@ -50,7 +52,7 @@ internal object SharedPreferencesManager { fun put( key: String, value: Boolean - ) = preferences.edit().putBoolean(key, value).apply() + ) = runCatching { preferences.edit().putBoolean(key, value).apply() }.logOnException() /** * Saves [Int] into the Preferences. @@ -61,7 +63,7 @@ internal object SharedPreferencesManager { fun put( key: String, value: Int - ) = preferences.edit().putInt(key, value).apply() + ) = runCatching { preferences.edit().putInt(key, value).apply() }.logOnException() /** * Used to retrieve [String] object from the Preferences. @@ -73,7 +75,9 @@ internal object SharedPreferencesManager { fun getString( key: String, defaultValue: String? = null - ): String? = preferences.getString(key, defaultValue) + ): String? = runCatching { + preferences.getString(key, defaultValue) + }.returnOnException { defaultValue } /** * Used to retrieve [Boolean] object from the Preferences. @@ -85,7 +89,9 @@ internal object SharedPreferencesManager { fun getBoolean( key: String, defaultValue: Boolean = false - ): Boolean = preferences.getBoolean(key, defaultValue) + ): Boolean = runCatching { + preferences.getBoolean(key, defaultValue) + }.returnOnException { defaultValue } /** * Used to retrieve [Int] object from the Preferences. @@ -97,8 +103,12 @@ internal object SharedPreferencesManager { fun getInt( key: String, defaultValue: Int = DEFAULT_INT_VALUE - ): Int = preferences.getInt(key, defaultValue) + ): Int = runCatching { + preferences.getInt(key, defaultValue) + }.returnOnException { defaultValue } - internal fun deleteAll() = preferences.edit().clear().apply() + internal fun deleteAll() = runCatching { + preferences.edit().clear().apply() + }.exceptionOrNull() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/InitData.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/InitData.kt index f5275f0fe..74e483005 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/InitData.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/InitData.kt @@ -1,9 +1,14 @@ package cloud.mindbox.mobile_sdk.models +import androidx.annotation.StringDef import com.google.gson.annotations.SerializedName private const val INIT_DATA_VERSION = 0 +internal const val DIRECT = "direct" +internal const val LINK = "link" +internal const val PUSH = "push" + internal data class InitData( @SerializedName("token") val token: String, @SerializedName("isTokenAvailable") val isTokenAvailable: Boolean, @@ -30,5 +35,10 @@ internal data class TrackClickData( internal data class TrackVisitData( @SerializedName("ianaTimeZone") val ianaTimeZone: String, - @SerializedName("endpointId") val endpointId: String + @SerializedName("endpointId") val endpointId: String, + @SerializedName("source") @TrackVisitSource val source: String? = null, + @SerializedName("requestUrl") val requestUrl: String? = null ) + +@StringDef(DIRECT, LINK, PUSH) +internal annotation class TrackVisitSource