diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index 3b85c8d44af..7272f1d0a37 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -18,8 +18,10 @@ package com.wire.android +import android.app.Activity import android.content.ComponentCallbacks2 import android.os.Build +import android.os.Bundle import android.os.StrictMode import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.Configuration @@ -28,10 +30,12 @@ import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.di.ApplicationScope import com.wire.android.di.KaliumCoreLogic -import com.wire.android.util.CurrentScreenManager import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl import com.wire.android.feature.analytics.AnonymousAnalyticsRecorderImpl +import com.wire.android.feature.analytics.globalAnalyticsManager +import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.feature.analytics.model.AnalyticsSettings +import com.wire.android.util.CurrentScreenManager import com.wire.android.util.DataDogLogger import com.wire.android.util.LogFileWriter import com.wire.android.util.getGitBuildId @@ -46,8 +50,11 @@ import dagger.Lazy import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -95,6 +102,8 @@ class WireApplication : BaseApp() { enableStrictMode() + startActivityLifecycleCallback() + globalAppScope.launch { initializeApplicationLoggingFrameworks() @@ -134,6 +143,23 @@ class WireApplication : BaseApp() { } } + @Suppress("EmptyFunctionBlock") + private fun startActivityLifecycleCallback() { + registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityStarted(activity: Activity) { + globalAnalyticsManager.onStart(activity) + } + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) { + globalAnalyticsManager.onStop(activity) + } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + }) + } + private suspend fun initializeApplicationLoggingFrameworks() { // 1. Datadog should be initialized first ExternalLoggerManager.initDatadogLogger(applicationContext, globalDataStore.get()) @@ -159,30 +185,42 @@ class WireApplication : BaseApp() { initializeAnonymousAnalytics() } - private suspend fun initializeAnonymousAnalytics() { + private fun initializeAnonymousAnalytics() { if (!BuildConfig.ANALYTICS_ENABLED) return - globalAppScope.launch { - val anonymousAnalyticsRecorder = AnonymousAnalyticsRecorderImpl() - val analyticsSettings = AnalyticsSettings( - countlyAppKey = BuildConfig.ANALYTICS_APP_KEY, - countlyServerUrl = BuildConfig.ANALYTICS_SERVER_URL, - enableDebugLogging = BuildConfig.DEBUG - ) + val anonymousAnalyticsRecorder = AnonymousAnalyticsRecorderImpl() + val analyticsSettings = AnalyticsSettings( + countlyAppKey = BuildConfig.ANALYTICS_APP_KEY, + countlyServerUrl = BuildConfig.ANALYTICS_SERVER_URL, + enableDebugLogging = BuildConfig.DEBUG + ) - coreLogic.get().getGlobalScope().session.currentSessionFlow().collectLatest { sessionResult -> + val isAnonymousUsageDataEnabledFlow = coreLogic.get().getGlobalScope().session.currentSessionFlow() + .flatMapLatest { sessionResult -> if (sessionResult is CurrentSessionResult.Success && sessionResult.accountInfo.isValid()) { - val userDataStore = userDataStoreProvider.get().getOrCreate(sessionResult.accountInfo.userId) - - AnonymousAnalyticsManagerImpl.init( - context = this@WireApplication, - analyticsSettings = analyticsSettings, - isEnabledFlowProvider = userDataStore::isAnonymousUsageDataEnabled, - anonymousAnalyticsRecorder = anonymousAnalyticsRecorder, - dispatcher = Dispatchers.IO - ) + userDataStoreProvider.get().getOrCreate(sessionResult.accountInfo.userId).isAnonymousUsageDataEnabled() + } else { + flowOf(false) } } + .distinctUntilChanged() + + AnonymousAnalyticsManagerImpl.init( + context = this, + analyticsSettings = analyticsSettings, + isEnabledFlow = isAnonymousUsageDataEnabledFlow, + anonymousAnalyticsRecorder = anonymousAnalyticsRecorder, + dispatcher = Dispatchers.IO + ) + + // observe the app visibility state and send AppOpen event if the app goes from the background to the foreground + globalAppScope.launch { + currentScreenManager + .isAppVisibleFlow() + .filter { isVisible -> isVisible } + .collect { + globalAnalyticsManager.sendEvent(AnalyticsEvent.AppOpen()) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index e072c5df087..96b34ea338e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -41,12 +41,10 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -60,8 +58,6 @@ import com.wire.android.config.CustomUiConfigurationProvider import com.wire.android.config.LocalCustomUiConfigurationProvider import com.wire.android.datastore.UserDataStore import com.wire.android.feature.NavigationSwitchAccountActions -import com.wire.android.feature.analytics.globalAnalyticsManager -import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.LocalNavigator import com.wire.android.navigation.NavigationCommand @@ -298,25 +294,6 @@ class WireActivity : AppCompatActivity() { navController.removeOnDestinationChangedListener(currentScreenManager) } } - - val lifecycleOwner = LocalLifecycleOwner.current - val activity = LocalContext.current as Activity - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_START) { - globalAnalyticsManager.onStart(activity = activity) - globalAnalyticsManager.sendEvent(AnalyticsEvent.AppOpen()) - } - if (event == Lifecycle.Event.ON_STOP) { - globalAnalyticsManager.onStop() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } } @Composable diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt index 045de507bfa..8010b7a5379 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt @@ -25,52 +25,71 @@ import com.wire.android.feature.analytics.model.AnalyticsSettings import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch object AnonymousAnalyticsManagerImpl : AnonymousAnalyticsManager { private const val TAG = "AnonymousAnalyticsManagerImpl" private var isAnonymousUsageDataEnabled = false private var anonymousAnalyticsRecorder: AnonymousAnalyticsRecorder? = null + private val startedActivities = mutableSetOf() + + init { + globalAnalyticsManager = this + } override fun init( context: Context, analyticsSettings: AnalyticsSettings, - isEnabledFlowProvider: suspend () -> Flow, + isEnabledFlow: Flow, anonymousAnalyticsRecorder: AnonymousAnalyticsRecorder, dispatcher: CoroutineDispatcher ) { this.anonymousAnalyticsRecorder = anonymousAnalyticsRecorder CoroutineScope(dispatcher).launch { - isEnabledFlowProvider().collect { enabled -> - isAnonymousUsageDataEnabled = enabled - if (enabled) { - anonymousAnalyticsRecorder.configure( - context = context, - analyticsSettings = analyticsSettings, - ) + isEnabledFlow + .collectLatest { enabled -> + synchronized(this@AnonymousAnalyticsManagerImpl) { + if (enabled) { + anonymousAnalyticsRecorder.configure( + context = context, + analyticsSettings = analyticsSettings, + ) + // start recording for all Activities started before the feature was enabled + startedActivities.forEach { activity -> + anonymousAnalyticsRecorder.onStart(activity = activity) + } + } else { + // immediately disable event tracking + anonymousAnalyticsRecorder.halt() + } + isAnonymousUsageDataEnabled = enabled + } } - } } - globalAnalyticsManager = this } - override fun onStart(activity: Activity) { - if (!isAnonymousUsageDataEnabled) return + override fun onStart(activity: Activity) = synchronized(this@AnonymousAnalyticsManagerImpl) { + startedActivities.add(activity) + + if (!isAnonymousUsageDataEnabled) return@synchronized anonymousAnalyticsRecorder?.onStart(activity = activity) ?: Log.w(TAG, "Calling onStart with a null recorder.") } - override fun onStop() { - if (!isAnonymousUsageDataEnabled) return + override fun onStop(activity: Activity) = synchronized(this@AnonymousAnalyticsManagerImpl) { + startedActivities.remove(activity) + + if (!isAnonymousUsageDataEnabled) return@synchronized anonymousAnalyticsRecorder?.onStop() ?: Log.w(TAG, "Calling onStop with a null recorder.") } - override fun sendEvent(event: AnalyticsEvent) { - if (!isAnonymousUsageDataEnabled) return + override fun sendEvent(event: AnalyticsEvent) = synchronized(this@AnonymousAnalyticsManagerImpl) { + if (!isAnonymousUsageDataEnabled) return@synchronized anonymousAnalyticsRecorder?.sendEvent(event = event) ?: Log.w(TAG, "Calling sendEvent with key : ${event.key} with a null recorder.") diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt index e14e24009d6..8f9a04c5523 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt @@ -65,4 +65,8 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { override fun sendEvent(event: AnalyticsEvent) { Countly.sharedInstance().events().recordEvent(event.key, event.toSegmentation()) } + + override fun halt() { + Countly.sharedInstance().halt() + } } diff --git a/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt b/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt index ed62ad88bda..0eabd08e942 100644 --- a/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt +++ b/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt @@ -49,7 +49,7 @@ class AnonymousAnalyticsManagerTest { manager.init( context = arrangement.context, analyticsSettings = Arrangement.analyticsSettings, - isEnabledFlowProvider = arrangement.isEnabledFlowProvider::consumeAsFlow, + isEnabledFlow = arrangement.isEnabledFlow.consumeAsFlow(), anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, dispatcher = dispatcher ) @@ -82,7 +82,7 @@ class AnonymousAnalyticsManagerTest { manager.init( context = arrangement.context, analyticsSettings = Arrangement.analyticsSettings, - isEnabledFlowProvider = arrangement.isEnabledFlowProvider::consumeAsFlow, + isEnabledFlow = arrangement.isEnabledFlow.consumeAsFlow(), anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, dispatcher = dispatcher ) @@ -114,7 +114,7 @@ class AnonymousAnalyticsManagerTest { manager.init( context = arrangement.context, analyticsSettings = Arrangement.analyticsSettings, - isEnabledFlowProvider = arrangement.isEnabledFlowProvider::consumeAsFlow, + isEnabledFlow = arrangement.isEnabledFlow.consumeAsFlow(), anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, dispatcher = dispatcher ) @@ -161,7 +161,7 @@ class AnonymousAnalyticsManagerTest { arrangement.toggleIsEnabledFlow(false) advanceUntilIdle() - manager.onStop() + manager.onStop(activity = mockk()) // then verify(exactly = 0) { @@ -169,6 +169,62 @@ class AnonymousAnalyticsManagerTest { } } + @Test + fun givenIsEnabledFlowIsFalseAndOneActivityStarted_whenTogglingEnabledToTrue_thenCallStartAfterToggled() = runTest(dispatcher) { + // given + val (arrangement, manager) = Arrangement() + .withAnonymousAnalyticsRecorderConfigure() + .toggleIsEnabledFlow(false) + .arrange() + val activity: Activity = mockk() + manager.onStart(activity) + manager.init( + context = arrangement.context, + analyticsSettings = Arrangement.analyticsSettings, + isEnabledFlow = arrangement.isEnabledFlow.consumeAsFlow(), + anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + dispatcher = dispatcher + ) + advanceUntilIdle() + verify(exactly = 0) { + arrangement.anonymousAnalyticsRecorder.onStart(activity) + } + + // when + arrangement.toggleIsEnabledFlow(true) + advanceUntilIdle() + + // then + verify(exactly = 1) { + arrangement.anonymousAnalyticsRecorder.onStart(activity) + } + } + + @Test + fun givenManagerInitialized_whenTogglingEnabledToFalse_thenHaltIsCalled() = runTest(dispatcher) { + // given + val (arrangement, manager) = Arrangement() + .withAnonymousAnalyticsRecorderConfigure() + .arrange() + manager.init( + context = arrangement.context, + analyticsSettings = Arrangement.analyticsSettings, + isEnabledFlow = arrangement.isEnabledFlow.consumeAsFlow(), + anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + dispatcher = dispatcher + ) + advanceUntilIdle() + + // when + arrangement.toggleIsEnabledFlow(false) + advanceUntilIdle() + + // then + verify(exactly = 1) { + arrangement.anonymousAnalyticsRecorder.halt() + } + } + private class Arrangement { @MockK lateinit var context: Context @@ -176,7 +232,7 @@ class AnonymousAnalyticsManagerTest { @MockK lateinit var anonymousAnalyticsRecorder: AnonymousAnalyticsRecorder - val isEnabledFlowProvider = Channel(capacity = Channel.UNLIMITED) + val isEnabledFlow = Channel(capacity = Channel.UNLIMITED) init { MockKAnnotations.init(this, relaxUnitFun = true) @@ -193,7 +249,7 @@ class AnonymousAnalyticsManagerTest { } suspend fun toggleIsEnabledFlow(enabled: Boolean) = apply { - isEnabledFlowProvider.send(enabled) + isEnabledFlow.send(enabled) } companion object { diff --git a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManager.kt b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManager.kt index 19fd1ba0087..0899594e55f 100644 --- a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManager.kt +++ b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManager.kt @@ -31,7 +31,7 @@ interface AnonymousAnalyticsManager { fun init( context: Context, analyticsSettings: AnalyticsSettings, - isEnabledFlowProvider: suspend () -> Flow, + isEnabledFlow: Flow, anonymousAnalyticsRecorder: AnonymousAnalyticsRecorder, dispatcher: CoroutineDispatcher ) @@ -40,5 +40,5 @@ interface AnonymousAnalyticsManager { fun onStart(activity: Activity) - fun onStop() + fun onStop(activity: Activity) } diff --git a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerStub.kt b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerStub.kt index 8d68e23a29c..c1a495770e1 100644 --- a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerStub.kt +++ b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerStub.kt @@ -29,7 +29,7 @@ open class AnonymousAnalyticsManagerStub : AnonymousAnalyticsManager { override fun init( context: Context, analyticsSettings: AnalyticsSettings, - isEnabledFlowProvider: suspend () -> Flow, + isEnabledFlow: Flow, anonymousAnalyticsRecorder: AnonymousAnalyticsRecorder, dispatcher: CoroutineDispatcher ) = Unit @@ -38,5 +38,5 @@ open class AnonymousAnalyticsManagerStub : AnonymousAnalyticsManager { override fun onStart(activity: Activity) = Unit - override fun onStop() = Unit + override fun onStop(activity: Activity) = Unit } diff --git a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorder.kt b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorder.kt index a9a99d5b9e5..c9d79d9f61a 100644 --- a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorder.kt +++ b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorder.kt @@ -33,4 +33,6 @@ interface AnonymousAnalyticsRecorder { fun onStop() fun sendEvent(event: AnalyticsEvent) + + fun halt() } diff --git a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderStub.kt b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderStub.kt index de30e3ad93a..85164c548e0 100644 --- a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderStub.kt +++ b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderStub.kt @@ -30,4 +30,6 @@ open class AnonymousAnalyticsRecorderStub : AnonymousAnalyticsRecorder { override fun onStop() = Unit override fun sendEvent(event: AnalyticsEvent) = Unit + + override fun halt() = Unit }