From 9fa30d703f29edc85347c747ef3d0d6657e2ea69 Mon Sep 17 00:00:00 2001 From: Alexandre Ferris Date: Fri, 2 Aug 2024 10:52:11 +0200 Subject: [PATCH] feat: countly cherrypick (WPB-8978) (#3270) Signed-off-by: alexandreferris --- .../com/wire/android/WireApplication.kt | 35 ++-- .../ObserveCurrentSessionAnalyticsUseCase.kt | 102 +++++++++ ...serveCurrentSessionAnalyticsUseCaseTest.kt | 196 ++++++++++++++++++ core/analytics-enabled/build.gradle.kts | 1 + .../AnonymousAnalyticsManagerImpl.kt | 117 +++++++++-- .../AnonymousAnalyticsRecorderImpl.kt | 36 ++++ .../AnonymousAnalyticsManagerTest.kt | 112 +++++++--- core/analytics/build.gradle.kts | 2 + .../analytics/AnonymousAnalyticsManager.kt | 10 +- .../AnonymousAnalyticsManagerStub.kt | 9 +- .../analytics/AnonymousAnalyticsRecorder.kt | 13 ++ .../AnonymousAnalyticsRecorderStub.kt | 13 ++ .../handler/AnalyticsMigrationHandler.kt | 22 ++ .../handler/AnalyticsPropagationHandler.kt | 22 ++ .../feature/analytics/model/AnalyticsEvent.kt | 1 + .../analytics/model/AnalyticsResult.kt | 26 +++ include_builds.gradle.kts | 1 + kalium | 2 +- 18 files changed, 655 insertions(+), 65 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCase.kt create mode 100644 app/src/test/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCaseTest.kt create mode 100644 core/analytics/src/main/kotlin/com/wire/android/feature/analytics/handler/AnalyticsMigrationHandler.kt create mode 100644 core/analytics/src/main/kotlin/com/wire/android/feature/analytics/handler/AnalyticsPropagationHandler.kt create mode 100644 core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsResult.kt diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index 7d65d0c1f8a..82b98bec81e 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -26,6 +26,7 @@ import android.os.StrictMode import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.Configuration import co.touchlab.kermit.platformLogWriter +import com.wire.android.analytics.ObserveCurrentSessionAnalyticsUseCase import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.di.ApplicationScope @@ -45,16 +46,12 @@ import com.wire.kalium.logger.KaliumLogLevel import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.logic.CoreLogger import com.wire.kalium.logic.CoreLogic -import com.wire.kalium.logic.feature.session.CurrentSessionResult import dagger.Lazy import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -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 @@ -195,21 +192,31 @@ class WireApplication : BaseApp() { enableDebugLogging = BuildConfig.DEBUG ) - val isAnonymousUsageDataEnabledFlow = coreLogic.get().getGlobalScope().session.currentSessionFlow() - .flatMapLatest { sessionResult -> - if (sessionResult is CurrentSessionResult.Success && sessionResult.accountInfo.isValid()) { - userDataStoreProvider.get().getOrCreate(sessionResult.accountInfo.userId).isAnonymousUsageDataEnabled() - } else { - flowOf(false) - } - } - .distinctUntilChanged() + val analyticsResultFlow = ObserveCurrentSessionAnalyticsUseCase( + currentSessionFlow = coreLogic.get().getGlobalScope().session.currentSessionFlow(), + isUserTeamMember = { + coreLogic.get().getSessionScope(it).team.isSelfATeamMember() + }, + observeAnalyticsTrackingIdentifierStatusFlow = { + coreLogic.get().getSessionScope(it).observeAnalyticsTrackingIdentifierStatus() + }, + analyticsIdentifierManagerProvider = { + coreLogic.get().getSessionScope(it).analyticsIdentifierManager + }, + userDataStoreProvider = userDataStoreProvider.get() + ).invoke() AnonymousAnalyticsManagerImpl.init( context = this, analyticsSettings = analyticsSettings, - isEnabledFlow = isAnonymousUsageDataEnabledFlow, + analyticsResultFlow = analyticsResultFlow, anonymousAnalyticsRecorder = anonymousAnalyticsRecorder, + propagationHandler = { manager, identifier -> + manager.propagateTrackingIdentifier(identifier) + }, + migrationHandler = { manager -> + manager.onMigrationComplete() + }, dispatcher = Dispatchers.IO ) diff --git a/app/src/main/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCase.kt b/app/src/main/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCase.kt new file mode 100644 index 00000000000..d90cdad547c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCase.kt @@ -0,0 +1,102 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.analytics + +import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.feature.analytics.model.AnalyticsResult +import com.wire.kalium.logic.data.analytics.AnalyticsIdentifierResult +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.analytics.AnalyticsIdentifierManager +import com.wire.kalium.logic.feature.session.CurrentSessionResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf + +interface ObserveCurrentSessionAnalyticsUseCase { + + /** + * Observes a flow of AnalyticsResult of type AnalyticsIdentifierManager + * returning the current result for analytics: + * - newly generated / existing / migration + * + * to be used in analytics user profile device setting. + */ + operator fun invoke(): Flow> +} + +@Suppress("FunctionNaming") +fun ObserveCurrentSessionAnalyticsUseCase( + currentSessionFlow: Flow, + isUserTeamMember: suspend (UserId) -> Boolean, + observeAnalyticsTrackingIdentifierStatusFlow: suspend (UserId) -> Flow, + analyticsIdentifierManagerProvider: (UserId) -> AnalyticsIdentifierManager, + userDataStoreProvider: UserDataStoreProvider +) = object : ObserveCurrentSessionAnalyticsUseCase { + + private var previousAnalyticsResult: AnalyticsIdentifierResult? = null + + override fun invoke(): Flow> = + currentSessionFlow + .flatMapLatest { + if (it is CurrentSessionResult.Success && it.accountInfo.isValid()) { + val userId = it.accountInfo.userId + val isTeamMember = isUserTeamMember(userId) + val analyticsIdentifierManager = analyticsIdentifierManagerProvider(userId) + + combine( + observeAnalyticsTrackingIdentifierStatusFlow(userId) + .filter { currentIdentifierResult -> + val currentResult = (currentIdentifierResult as? AnalyticsIdentifierResult.Enabled) + val previousResult = (previousAnalyticsResult as? AnalyticsIdentifierResult.Enabled) + + currentIdentifierResult != previousAnalyticsResult && + currentResult?.identifier != previousResult?.identifier + }, + userDataStoreProvider.getOrCreate(userId).isAnonymousUsageDataEnabled() + ) { identifierResult, enabled -> + previousAnalyticsResult = identifierResult + + if (enabled) { + AnalyticsResult( + identifierResult = identifierResult, + isTeamMember = isTeamMember, + manager = analyticsIdentifierManager + ) + } else { + AnalyticsResult( + identifierResult = AnalyticsIdentifierResult.Disabled, + isTeamMember = isTeamMember, + manager = analyticsIdentifierManager + ) + } + } + } else { + flowOf( + AnalyticsResult( + identifierResult = AnalyticsIdentifierResult.Disabled, + isTeamMember = false, + manager = null + ) + ) + } + } + .distinctUntilChanged() +} diff --git a/app/src/test/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCaseTest.kt new file mode 100644 index 00000000000..070f9c30515 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCaseTest.kt @@ -0,0 +1,196 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.analytics + +import app.cash.turbine.test +import com.wire.android.assertIs +import com.wire.android.datastore.UserDataStore +import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.framework.TestUser +import com.wire.kalium.logic.data.analytics.AnalyticsIdentifierResult +import com.wire.kalium.logic.data.auth.AccountInfo +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.analytics.AnalyticsIdentifierManager +import com.wire.kalium.logic.feature.session.CurrentSessionResult +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.Test + +class ObserveCurrentSessionAnalyticsUseCaseTest { + + @Test + fun givenThereIsNoValidSession_whenObservingCurrentSessionAnalytics_thenDisabledAnalyticsResultIsReturned() = runTest { + // given + val (_, useCase) = Arrangement().apply { + setCurrentSession(CurrentSessionResult.Failure.SessionNotFound) + }.arrange() + + // when + useCase.invoke().test { + // then + val item = awaitItem() + assertIs(item.identifierResult) + assertEquals(false, item.isTeamMember) + assertEquals(null, item.manager) + } + } + + @Test + fun givenThereIsAValidSession_whenObservingCurrentSessionAnalytics_thenExistingIdentifierAnalyticsResultIsReturned() = runTest { + // given + val (_, useCase) = Arrangement() + .withIsAnonymousUsageDataEnabled(true) + .apply { + setCurrentSession(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.SELF_USER.id))) + setIsTeamMember(TestUser.SELF_USER.id) + setObservingTrackingIdentifierStatus(AnalyticsIdentifierResult.ExistingIdentifier(Arrangement.CURRENT_TRACKING_IDENTIFIER)) + }.arrange() + + // when + useCase.invoke().test { + // then + val item = awaitItem() + assertIs(item.identifierResult) + assertEquals(true, item.isTeamMember) + } + } + + @Test + fun givenThereIsAValidSessionAndDisabledUsageData_whenObservingCurrentSessionAnalytics_thenDisabledAnalyticsResultIsReturned() = + runTest { + // given + val (_, useCase) = Arrangement() + .withIsAnonymousUsageDataEnabled(false) + .apply { + setCurrentSession(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.SELF_USER.id))) + setIsTeamMember(TestUser.SELF_USER.id) + setObservingTrackingIdentifierStatus( + AnalyticsIdentifierResult.ExistingIdentifier(Arrangement.CURRENT_TRACKING_IDENTIFIER) + ) + }.arrange() + + // when + useCase.invoke().test { + // then + val item = awaitItem() + assertIs(item.identifierResult) + assertEquals(true, item.isTeamMember) + assertEquals(true, item.manager != null) + } + } + + @Test + fun givenUserSwitchAccount_whenObservingCurrentSessionAnalytics_thenExistingIdentifierAnalyticsResultIsReturned() = runTest { + // given + val (arrangement, useCase) = Arrangement() + .withIsAnonymousUsageDataEnabled(true) + .apply { + setCurrentSession(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.SELF_USER.id))) + setIsTeamMember(TestUser.SELF_USER.id) + setObservingTrackingIdentifierStatus(AnalyticsIdentifierResult.ExistingIdentifier(Arrangement.CURRENT_TRACKING_IDENTIFIER)) + }.arrange() + + // when + useCase.invoke().test { + // then + val item = awaitItem() + assertIs(item.identifierResult) + assertEquals(true, item.isTeamMember) + + // when changing user + arrangement.setCurrentSession(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.OTHER_USER.id))) + arrangement.setObservingTrackingIdentifierStatus( + AnalyticsIdentifierResult.ExistingIdentifier(Arrangement.OTHER_TRACKING_IDENTIFIER) + ) + arrangement.withIsAnonymousUsageDataEnabled(true) + + // then + val nextItem = awaitItem() + assertIs(nextItem.identifierResult) + assertEquals(false, nextItem.isTeamMember) + } + } + + private class Arrangement { + + @MockK + private lateinit var userDataStore: UserDataStore + + @MockK + private lateinit var userDataStoreProvider: UserDataStoreProvider + + @MockK + private lateinit var analyticsIdentifierManager: AnalyticsIdentifierManager + + private val currentSessionChannel = Channel(Channel.UNLIMITED) + + private val analyticsTrackingIdentifierStatusChannel = Channel(Channel.UNLIMITED) + + private val teamMembers = mutableSetOf() + + private val isTeamMember: (UserId) -> Boolean = { teamMembers.contains(it) } + + init { + // Tests setup + MockKAnnotations.init(this, relaxUnitFun = true) + } + + suspend fun setCurrentSession(result: CurrentSessionResult) { + currentSessionChannel.send(result) + } + + fun setIsTeamMember(userId: UserId) { + teamMembers.add(userId) + } + + suspend fun setObservingTrackingIdentifierStatus(result: AnalyticsIdentifierResult) { + analyticsTrackingIdentifierStatusChannel.send(result) + } + + fun withIsAnonymousUsageDataEnabled(result: Boolean): Arrangement = apply { + every { userDataStoreProvider.getOrCreate(any()) } returns userDataStore + coEvery { userDataStore.isAnonymousUsageDataEnabled() } returns flowOf(result) + } + + var useCase: ObserveCurrentSessionAnalyticsUseCase = ObserveCurrentSessionAnalyticsUseCase( + currentSessionFlow = currentSessionChannel.receiveAsFlow(), + isUserTeamMember = isTeamMember, + observeAnalyticsTrackingIdentifierStatusFlow = { + analyticsTrackingIdentifierStatusChannel.receiveAsFlow() + }, + analyticsIdentifierManagerProvider = { + analyticsIdentifierManager + }, + userDataStoreProvider = userDataStoreProvider + ) + + fun arrange() = this to useCase + + companion object { + const val CURRENT_TRACKING_IDENTIFIER = "abcd-1234" + const val OTHER_TRACKING_IDENTIFIER = "aaaa-bbbb-1234" + } + } +} diff --git a/core/analytics-enabled/build.gradle.kts b/core/analytics-enabled/build.gradle.kts index c416711f80c..ef37bc49626 100644 --- a/core/analytics-enabled/build.gradle.kts +++ b/core/analytics-enabled/build.gradle.kts @@ -7,6 +7,7 @@ dependencies { implementation(libs.androidx.core) implementation(libs.androidx.appcompat) + implementation("com.wire.kalium:kalium-data") api(project(":core:analytics")) val composeBom = platform(libs.compose.bom) 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 8010b7a5379..08a95836eba 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 @@ -20,38 +20,51 @@ package com.wire.android.feature.analytics import android.app.Activity import android.content.Context import android.util.Log +import com.wire.android.feature.analytics.handler.AnalyticsMigrationHandler +import com.wire.android.feature.analytics.handler.AnalyticsPropagationHandler import com.wire.android.feature.analytics.model.AnalyticsEvent +import com.wire.android.feature.analytics.model.AnalyticsResult import com.wire.android.feature.analytics.model.AnalyticsSettings +import com.wire.kalium.logic.data.analytics.AnalyticsIdentifierResult import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock object AnonymousAnalyticsManagerImpl : AnonymousAnalyticsManager { private const val TAG = "AnonymousAnalyticsManagerImpl" private var isAnonymousUsageDataEnabled = false private var anonymousAnalyticsRecorder: AnonymousAnalyticsRecorder? = null private val startedActivities = mutableSetOf() + private val mutex = Mutex() + private lateinit var coroutineScope: CoroutineScope init { globalAnalyticsManager = this } - override fun init( + override fun init( context: Context, analyticsSettings: AnalyticsSettings, - isEnabledFlow: Flow, + analyticsResultFlow: Flow>, anonymousAnalyticsRecorder: AnonymousAnalyticsRecorder, + migrationHandler: AnalyticsMigrationHandler, + propagationHandler: AnalyticsPropagationHandler, dispatcher: CoroutineDispatcher ) { + this.coroutineScope = CoroutineScope(dispatcher) this.anonymousAnalyticsRecorder = anonymousAnalyticsRecorder - CoroutineScope(dispatcher).launch { - isEnabledFlow - .collectLatest { enabled -> - synchronized(this@AnonymousAnalyticsManagerImpl) { - if (enabled) { + coroutineScope.launch { + analyticsResultFlow + .collectLatest { analyticsResult -> + mutex.withLock { + val result = analyticsResult.identifierResult + + if (result is AnalyticsIdentifierResult.Enabled) { anonymousAnalyticsRecorder.configure( context = context, analyticsSettings = analyticsSettings, @@ -60,38 +73,98 @@ object AnonymousAnalyticsManagerImpl : AnonymousAnalyticsManager { startedActivities.forEach { activity -> anonymousAnalyticsRecorder.onStart(activity = activity) } + + handleTrackingIdentifier( + analyticsIdentifierResult = analyticsResult.identifierResult, + isTeamMember = analyticsResult.isTeamMember, + propagateIdentifier = { + analyticsResult.manager?.let { propagationHandler.propagate(it, result.identifier) } + }, + migrationComplete = { + analyticsResult.manager?.let { migrationHandler.migrate(it) } + } + ) } else { // immediately disable event tracking anonymousAnalyticsRecorder.halt() } - isAnonymousUsageDataEnabled = enabled + isAnonymousUsageDataEnabled = result is AnalyticsIdentifierResult.Enabled } } } } - override fun onStart(activity: Activity) = synchronized(this@AnonymousAnalyticsManagerImpl) { - startedActivities.add(activity) + override fun onStart(activity: Activity) { + coroutineScope.launch { + mutex.withLock { + startedActivities.add(activity) - if (!isAnonymousUsageDataEnabled) return@synchronized + if (!isAnonymousUsageDataEnabled) return@withLock - anonymousAnalyticsRecorder?.onStart(activity = activity) - ?: Log.w(TAG, "Calling onStart with a null recorder.") + anonymousAnalyticsRecorder?.onStart(activity = activity) + ?: Log.w(TAG, "Calling onStart with a null recorder.") + } + } } - override fun onStop(activity: Activity) = synchronized(this@AnonymousAnalyticsManagerImpl) { - startedActivities.remove(activity) + override fun onStop(activity: Activity) { + coroutineScope.launch { + mutex.withLock { + startedActivities.remove(activity) - if (!isAnonymousUsageDataEnabled) return@synchronized + if (!isAnonymousUsageDataEnabled) return@withLock - anonymousAnalyticsRecorder?.onStop() - ?: Log.w(TAG, "Calling onStop with a null recorder.") + anonymousAnalyticsRecorder?.onStop() + ?: Log.w(TAG, "Calling onStop with a null recorder.") + } + } } - override fun sendEvent(event: AnalyticsEvent) = synchronized(this@AnonymousAnalyticsManagerImpl) { - if (!isAnonymousUsageDataEnabled) return@synchronized + override fun sendEvent(event: AnalyticsEvent) { + coroutineScope.launch { + mutex.withLock { + if (!isAnonymousUsageDataEnabled) return@withLock - anonymousAnalyticsRecorder?.sendEvent(event = event) - ?: Log.w(TAG, "Calling sendEvent with key : ${event.key} with a null recorder.") + anonymousAnalyticsRecorder?.sendEvent(event = event) + ?: Log.w(TAG, "Calling sendEvent with key : ${event.key} with a null recorder.") + } + } + } + + private suspend fun handleTrackingIdentifier( + analyticsIdentifierResult: AnalyticsIdentifierResult, + isTeamMember: Boolean, + propagateIdentifier: suspend () -> Unit, + migrationComplete: suspend () -> Unit + ) { + when (analyticsIdentifierResult) { + is AnalyticsIdentifierResult.NonExistingIdentifier -> { + anonymousAnalyticsRecorder?.setTrackingIdentifierWithoutMerge( + identifier = analyticsIdentifierResult.identifier, + shouldPropagateIdentifier = true, + isTeamMember = isTeamMember, + propagateIdentifier = propagateIdentifier + ) + } + + is AnalyticsIdentifierResult.ExistingIdentifier -> { + anonymousAnalyticsRecorder?.setTrackingIdentifierWithoutMerge( + identifier = analyticsIdentifierResult.identifier, + shouldPropagateIdentifier = false, + isTeamMember = isTeamMember, + propagateIdentifier = {} + ) + } + + is AnalyticsIdentifierResult.MigrationIdentifier -> { + anonymousAnalyticsRecorder?.setTrackingIdentifierWithMerge( + identifier = analyticsIdentifierResult.identifier, + isTeamMember = isTeamMember, + migrationComplete = migrationComplete + ) + } + + is AnalyticsIdentifierResult.Disabled -> {} + } } } 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 8f9a04c5523..4252fb052aa 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 @@ -67,6 +67,42 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { } override fun halt() { + isConfigured = false Countly.sharedInstance().halt() } + + override suspend fun setTrackingIdentifierWithMerge( + identifier: String, + isTeamMember: Boolean, + migrationComplete: suspend () -> Unit + ) { + Countly.sharedInstance().deviceId().changeWithMerge(identifier).also { + migrationComplete() + } + + setUserProfileProperties(isTeamMember = isTeamMember) + } + + override suspend fun setTrackingIdentifierWithoutMerge( + identifier: String, + shouldPropagateIdentifier: Boolean, + isTeamMember: Boolean, + propagateIdentifier: suspend () -> Unit + ) { + Countly.sharedInstance().deviceId().changeWithoutMerge(identifier) + + setUserProfileProperties(isTeamMember = isTeamMember) + + if (shouldPropagateIdentifier) { + propagateIdentifier() + } + } + + private fun setUserProfileProperties(isTeamMember: Boolean) { + Countly.sharedInstance().userProfile().setProperty( + AnalyticsEventConstants.TEAM_IS_TEAM, + isTeamMember + ) + Countly.sharedInstance().userProfile().save() + } } 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 0eabd08e942..ce8ede35d63 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 @@ -19,9 +19,14 @@ package com.wire.android.feature.analytics import android.app.Activity import android.content.Context +import com.wire.android.feature.analytics.handler.AnalyticsMigrationHandler +import com.wire.android.feature.analytics.handler.AnalyticsPropagationHandler import com.wire.android.feature.analytics.model.AnalyticsEvent +import com.wire.android.feature.analytics.model.AnalyticsResult import com.wire.android.feature.analytics.model.AnalyticsSettings +import com.wire.kalium.logic.data.analytics.AnalyticsIdentifierResult import io.mockk.MockKAnnotations +import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk @@ -43,14 +48,16 @@ class AnonymousAnalyticsManagerTest { .withAnonymousAnalyticsRecorderConfigure() .arrange() - arrangement.toggleIsEnabledFlow(true) + arrangement.withAnalyticsResult(Arrangement.existingIdentifierResult) // when manager.init( context = arrangement.context, analyticsSettings = Arrangement.analyticsSettings, - isEnabledFlow = arrangement.isEnabledFlow.consumeAsFlow(), + analyticsResultFlow = arrangement.analyticsResultChannel.consumeAsFlow(), anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + migrationHandler = arrangement.migrationHandler, + propagationHandler = arrangement.propagationHandler, dispatcher = dispatcher ) advanceUntilIdle() @@ -76,23 +83,26 @@ class AnonymousAnalyticsManagerTest { attribute1 = "attr1" ) - arrangement.toggleIsEnabledFlow(true) + arrangement.withAnalyticsResult(Arrangement.existingIdentifierResult) // when manager.init( context = arrangement.context, analyticsSettings = Arrangement.analyticsSettings, - isEnabledFlow = arrangement.isEnabledFlow.consumeAsFlow(), + analyticsResultFlow = arrangement.analyticsResultChannel.consumeAsFlow(), anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + migrationHandler = arrangement.migrationHandler, + propagationHandler = arrangement.propagationHandler, dispatcher = dispatcher ) advanceUntilIdle() manager.sendEvent(event) + advanceUntilIdle() // then verify(exactly = 1) { - arrangement.anonymousAnalyticsRecorder.sendEvent(event) + arrangement.anonymousAnalyticsRecorder.sendEvent(any()) } } @@ -108,21 +118,22 @@ class AnonymousAnalyticsManagerTest { attribute1 = "attr1" ) - arrangement.toggleIsEnabledFlow(true) + arrangement.withAnalyticsResult(Arrangement.existingIdentifierResult) // when manager.init( context = arrangement.context, analyticsSettings = Arrangement.analyticsSettings, - isEnabledFlow = arrangement.isEnabledFlow.consumeAsFlow(), + analyticsResultFlow = arrangement.analyticsResultChannel.consumeAsFlow(), anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + migrationHandler = arrangement.migrationHandler, + propagationHandler = arrangement.propagationHandler, dispatcher = dispatcher ) - advanceUntilIdle() manager.sendEvent(event) - arrangement.toggleIsEnabledFlow(false) + arrangement.withAnalyticsResult(Arrangement.disabledIdentifierResult) advanceUntilIdle() manager.sendEvent(event) @@ -140,8 +151,18 @@ class AnonymousAnalyticsManagerTest { .withAnonymousAnalyticsRecorderConfigure() .arrange() - arrangement.toggleIsEnabledFlow(false) - advanceUntilIdle() + arrangement.withAnalyticsResult(Arrangement.disabledIdentifierResult) + + // when + manager.init( + context = arrangement.context, + analyticsSettings = Arrangement.analyticsSettings, + analyticsResultFlow = arrangement.analyticsResultChannel.consumeAsFlow(), + anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + migrationHandler = arrangement.migrationHandler, + propagationHandler = arrangement.propagationHandler, + dispatcher = dispatcher + ) manager.onStart(activity = mockk()) @@ -158,10 +179,21 @@ class AnonymousAnalyticsManagerTest { .withAnonymousAnalyticsRecorderConfigure() .arrange() - arrangement.toggleIsEnabledFlow(false) - advanceUntilIdle() + arrangement.withAnalyticsResult(Arrangement.disabledIdentifierResult) + + // when + manager.init( + context = arrangement.context, + analyticsSettings = Arrangement.analyticsSettings, + analyticsResultFlow = arrangement.analyticsResultChannel.consumeAsFlow(), + anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + migrationHandler = arrangement.migrationHandler, + propagationHandler = arrangement.propagationHandler, + dispatcher = dispatcher + ) manager.onStop(activity = mockk()) + advanceUntilIdle() // then verify(exactly = 0) { @@ -174,24 +206,28 @@ class AnonymousAnalyticsManagerTest { // given val (arrangement, manager) = Arrangement() .withAnonymousAnalyticsRecorderConfigure() - .toggleIsEnabledFlow(false) + .withAnalyticsResult(Arrangement.disabledIdentifierResult) .arrange() val activity: Activity = mockk() - manager.onStart(activity) + manager.init( context = arrangement.context, analyticsSettings = Arrangement.analyticsSettings, - isEnabledFlow = arrangement.isEnabledFlow.consumeAsFlow(), + analyticsResultFlow = arrangement.analyticsResultChannel.consumeAsFlow(), anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + migrationHandler = arrangement.migrationHandler, + propagationHandler = arrangement.propagationHandler, dispatcher = dispatcher ) - advanceUntilIdle() + + manager.onStart(activity) + verify(exactly = 0) { arrangement.anonymousAnalyticsRecorder.onStart(activity) } // when - arrangement.toggleIsEnabledFlow(true) + arrangement.withAnalyticsResult(Arrangement.existingIdentifierResult) advanceUntilIdle() // then @@ -206,17 +242,19 @@ class AnonymousAnalyticsManagerTest { val (arrangement, manager) = Arrangement() .withAnonymousAnalyticsRecorderConfigure() .arrange() + manager.init( context = arrangement.context, analyticsSettings = Arrangement.analyticsSettings, - isEnabledFlow = arrangement.isEnabledFlow.consumeAsFlow(), + analyticsResultFlow = arrangement.analyticsResultChannel.consumeAsFlow(), anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + migrationHandler = arrangement.migrationHandler, + propagationHandler = arrangement.propagationHandler, dispatcher = dispatcher ) - advanceUntilIdle() // when - arrangement.toggleIsEnabledFlow(false) + arrangement.withAnalyticsResult(Arrangement.disabledIdentifierResult) advanceUntilIdle() // then @@ -232,10 +270,22 @@ class AnonymousAnalyticsManagerTest { @MockK lateinit var anonymousAnalyticsRecorder: AnonymousAnalyticsRecorder - val isEnabledFlow = Channel(capacity = Channel.UNLIMITED) + @MockK + lateinit var migrationHandler: AnalyticsMigrationHandler + + @MockK + lateinit var propagationHandler: AnalyticsPropagationHandler + + val analyticsResultChannel = Channel>(capacity = Channel.UNLIMITED) init { MockKAnnotations.init(this, relaxUnitFun = true) + + every { anonymousAnalyticsRecorder.onStop() } returns Unit + every { anonymousAnalyticsRecorder.onStart(any()) } returns Unit + every { anonymousAnalyticsRecorder.sendEvent(any()) } returns Unit + coEvery { anonymousAnalyticsRecorder.setTrackingIdentifierWithMerge(any(), any(), any()) } returns Unit + coEvery { anonymousAnalyticsRecorder.setTrackingIdentifierWithoutMerge(any(), any(), any(), any()) } returns Unit } private val manager by lazy { @@ -248,16 +298,18 @@ class AnonymousAnalyticsManagerTest { every { anonymousAnalyticsRecorder.configure(any(), any()) } returns Unit } - suspend fun toggleIsEnabledFlow(enabled: Boolean) = apply { - isEnabledFlow.send(enabled) + suspend fun withAnalyticsResult(result: AnalyticsResult) = apply { + analyticsResultChannel.send(result) } companion object { + const val CURRENT_IDENTIFIER = "abcd-1234" val analyticsSettings = AnalyticsSettings( countlyAppKey = "appKey", countlyServerUrl = "serverUrl", enableDebugLogging = true ) + data class DummyEvent( override val key: String, val attribute1: String @@ -266,6 +318,18 @@ class AnonymousAnalyticsManagerTest { "attribute1" to attribute1 ) } + + interface DummyManager + + private fun dummyManager() = object : DummyManager {} + val existingIdentifierResult = AnalyticsResult( + identifierResult = AnalyticsIdentifierResult.ExistingIdentifier(CURRENT_IDENTIFIER), + isTeamMember = true, + manager = dummyManager() + ) + val disabledIdentifierResult = existingIdentifierResult.copy( + identifierResult = AnalyticsIdentifierResult.Disabled + ) } } } diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts index 33ccfcf8e31..a40f795b64d 100644 --- a/core/analytics/build.gradle.kts +++ b/core/analytics/build.gradle.kts @@ -7,6 +7,8 @@ dependencies { implementation(libs.androidx.core) implementation(libs.androidx.appcompat) + implementation("com.wire.kalium:kalium-data") + val composeBom = platform(libs.compose.bom) implementation(composeBom) implementation(libs.compose.ui) 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 0899594e55f..915511e0172 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 @@ -19,7 +19,10 @@ package com.wire.android.feature.analytics import android.app.Activity import android.content.Context +import com.wire.android.feature.analytics.handler.AnalyticsMigrationHandler +import com.wire.android.feature.analytics.handler.AnalyticsPropagationHandler import com.wire.android.feature.analytics.model.AnalyticsEvent +import com.wire.android.feature.analytics.model.AnalyticsResult import com.wire.android.feature.analytics.model.AnalyticsSettings import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -28,11 +31,14 @@ var globalAnalyticsManager: AnonymousAnalyticsManager = AnonymousAnalyticsManage interface AnonymousAnalyticsManager { - fun init( + @Suppress("LongParameterList") + fun init( context: Context, analyticsSettings: AnalyticsSettings, - isEnabledFlow: Flow, + analyticsResultFlow: Flow>, anonymousAnalyticsRecorder: AnonymousAnalyticsRecorder, + migrationHandler: AnalyticsMigrationHandler, + propagationHandler: AnalyticsPropagationHandler, dispatcher: CoroutineDispatcher ) 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 c1a495770e1..ee102d87a40 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 @@ -19,18 +19,23 @@ package com.wire.android.feature.analytics import android.app.Activity import android.content.Context +import com.wire.android.feature.analytics.handler.AnalyticsMigrationHandler +import com.wire.android.feature.analytics.handler.AnalyticsPropagationHandler import com.wire.android.feature.analytics.model.AnalyticsEvent +import com.wire.android.feature.analytics.model.AnalyticsResult import com.wire.android.feature.analytics.model.AnalyticsSettings import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow open class AnonymousAnalyticsManagerStub : AnonymousAnalyticsManager { - override fun init( + override fun init( context: Context, analyticsSettings: AnalyticsSettings, - isEnabledFlow: Flow, + analyticsResultFlow: Flow>, anonymousAnalyticsRecorder: AnonymousAnalyticsRecorder, + migrationHandler: AnalyticsMigrationHandler, + propagationHandler: AnalyticsPropagationHandler, dispatcher: CoroutineDispatcher ) = 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 c9d79d9f61a..ef96db149ac 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 @@ -35,4 +35,17 @@ interface AnonymousAnalyticsRecorder { fun sendEvent(event: AnalyticsEvent) fun halt() + + suspend fun setTrackingIdentifierWithMerge( + identifier: String, + isTeamMember: Boolean, + migrationComplete: suspend () -> Unit + ) + + suspend fun setTrackingIdentifierWithoutMerge( + identifier: String, + shouldPropagateIdentifier: Boolean, + isTeamMember: Boolean, + propagateIdentifier: suspend () -> Unit + ) } 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 85164c548e0..56bf3fe4d39 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 @@ -32,4 +32,17 @@ open class AnonymousAnalyticsRecorderStub : AnonymousAnalyticsRecorder { override fun sendEvent(event: AnalyticsEvent) = Unit override fun halt() = Unit + + override suspend fun setTrackingIdentifierWithMerge( + identifier: String, + isTeamMember: Boolean, + migrationComplete: suspend () -> Unit + ) = Unit + + override suspend fun setTrackingIdentifierWithoutMerge( + identifier: String, + shouldPropagateIdentifier: Boolean, + isTeamMember: Boolean, + propagateIdentifier: suspend () -> Unit + ) = Unit } diff --git a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/handler/AnalyticsMigrationHandler.kt b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/handler/AnalyticsMigrationHandler.kt new file mode 100644 index 00000000000..ce5155d9167 --- /dev/null +++ b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/handler/AnalyticsMigrationHandler.kt @@ -0,0 +1,22 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.analytics.handler + +fun interface AnalyticsMigrationHandler { + suspend fun migrate(manager: T) +} diff --git a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/handler/AnalyticsPropagationHandler.kt b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/handler/AnalyticsPropagationHandler.kt new file mode 100644 index 00000000000..0e3d4d753e0 --- /dev/null +++ b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/handler/AnalyticsPropagationHandler.kt @@ -0,0 +1,22 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.analytics.handler + +fun interface AnalyticsPropagationHandler { + suspend fun propagate(manager: T, identifier: String) +} diff --git a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsEvent.kt b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsEvent.kt index f56c91ae2eb..d1f60fd31f0 100644 --- a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsEvent.kt +++ b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsEvent.kt @@ -55,5 +55,6 @@ object AnalyticsEventConstants { const val APP_NAME = "app_name" const val APP_NAME_ANDROID = "android" const val APP_VERSION = "app_version" + const val TEAM_IS_TEAM = "team_is_team" const val APP_OPEN = "app.open" } diff --git a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsResult.kt b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsResult.kt new file mode 100644 index 00000000000..fc13fd16492 --- /dev/null +++ b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsResult.kt @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.analytics.model + +import com.wire.kalium.logic.data.analytics.AnalyticsIdentifierResult + +data class AnalyticsResult( + val identifierResult: AnalyticsIdentifierResult, + val isTeamMember: Boolean, + val manager: T? +) diff --git a/include_builds.gradle.kts b/include_builds.gradle.kts index fdb331496a5..cea2c9a8a01 100644 --- a/include_builds.gradle.kts +++ b/include_builds.gradle.kts @@ -20,6 +20,7 @@ includeBuild("kalium") { dependencySubstitution { substitute(module("com.wire.kalium:kalium-logic")).using(project(":logic")) substitute(module("com.wire.kalium:kalium-util")).using(project(":util")) + substitute(module("com.wire.kalium:kalium-data")).using(project(":data")) // test modules substitute(module("com.wire.kalium:kalium-mocks")).using(project(":mocks")) substitute(module("com.wire.kalium:kalium-network")).using(project(":network")) diff --git a/kalium b/kalium index 8a02f965e46..717cad17f76 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 8a02f965e46134f52f4b16eaace5e222d1e6b0ac +Subproject commit 717cad17f76019ec820ad24b140f5c192a9e6eb4