diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index 01e38274662..71610bbd1cb 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -230,6 +230,7 @@ class WireApplication : BaseApp() { coreLogic.get().getSessionScope(userId).analyticsIdentifierManager }, userDataStoreProvider = userDataStoreProvider.get(), + globalDataStore = globalDataStore.get(), currentBackend = { userId -> coreLogic.get().getSessionScope(userId).users.serverLinks() } diff --git a/app/src/main/kotlin/com/wire/android/analytics/FinalizeRegistrationAnalyticsMetadataUseCase.kt b/app/src/main/kotlin/com/wire/android/analytics/FinalizeRegistrationAnalyticsMetadataUseCase.kt new file mode 100644 index 00000000000..fdf3703eb10 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/analytics/FinalizeRegistrationAnalyticsMetadataUseCase.kt @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2025 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.GlobalDataStore +import com.wire.android.di.CurrentAccount +import com.wire.android.di.KaliumCoreLogic +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.user.UserId +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject + +/** + * Finalize the registration process and analytics metadata in case there was enabled in the process. + * Transfers the identifier to the logged in user and clean up the registration process. + */ +class FinalizeRegistrationAnalyticsMetadataUseCase @Inject constructor( + private val globalDataStore: GlobalDataStore, + @CurrentAccount private val currentAccount: UserId, + @KaliumCoreLogic private val coreLogic: CoreLogic +) { + suspend operator fun invoke() { + if (globalDataStore.isAnonymousRegistrationEnabled().firstOrNull() == false) return + + val trackId = globalDataStore.getAnonymousRegistrationTrackId() + if (!trackId.isNullOrBlank()) { + coreLogic.getSessionScope(currentAccount).setNewUserTrackingIdentifier(trackId) + globalDataStore.clearAnonymousRegistrationTrackId() + globalDataStore.setAnonymousRegistrationEnabled(false) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCase.kt b/app/src/main/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCase.kt index cb133362751..3eefd5b79ca 100644 --- a/app/src/main/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCase.kt @@ -17,10 +17,11 @@ */ package com.wire.android.analytics +import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.feature.analytics.model.AnalyticsProfileProperties import com.wire.android.feature.analytics.model.AnalyticsResult -import com.wire.kalium.logic.configuration.server.ServerConfig +import com.wire.android.util.isHostValidForAnalytics import com.wire.kalium.logic.data.analytics.AnalyticsIdentifierResult import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.analytics.AnalyticsContactsData @@ -53,18 +54,42 @@ fun ObserveCurrentSessionAnalyticsUseCase( observeAnalyticsTrackingIdentifierStatusFlow: suspend (UserId) -> Flow, analyticsIdentifierManagerProvider: (UserId) -> AnalyticsIdentifierManager, userDataStoreProvider: UserDataStoreProvider, + globalDataStore: GlobalDataStore, currentBackend: suspend (UserId) -> SelfServerConfigUseCase.Result ) = object : ObserveCurrentSessionAnalyticsUseCase { private var previousAnalyticsResult: AnalyticsIdentifierResult? = null @Suppress("LongMethod") - override fun invoke(): Flow> = currentSessionFlow - .flatMapLatest { - if (it is CurrentSessionResult.Success && it.accountInfo.isValid()) { - val userId = it.accountInfo.userId - val analyticsIdentifierManager = analyticsIdentifierManagerProvider(userId) + override fun invoke(): Flow> { + return combine( + currentSessionFlow, + globalDataStore.isAnonymousRegistrationEnabled() + ) { currentSession, isAnonymousRegistrationEnabled -> + currentSession to isAnonymousRegistrationEnabled + }.flatMapLatest { (currentSession, isAnonymousRegistrationEnabled) -> + if (isAnonymousRegistrationEnabled) { + val anonymousRegistrationTrackId = globalDataStore.getOrCreateAnonymousRegistrationTrackId() + return@flatMapLatest flowOf( + AnalyticsResult( + identifierResult = AnalyticsIdentifierResult.RegistrationIdentifier(anonymousRegistrationTrackId), + profileProperties = { + AnalyticsProfileProperties( + isTeamMember = false, + teamId = null, + contactsAmount = null, + teamMembersAmount = null, + isEnterprise = null + ) + }, + manager = null + ) + ) + } + if (currentSession is CurrentSessionResult.Success && currentSession.accountInfo.isValid()) { + val userId = currentSession.accountInfo.userId + val analyticsIdentifierManager = analyticsIdentifierManagerProvider(userId) combine( observeAnalyticsTrackingIdentifierStatusFlow(userId) .filter { currentIdentifierResult -> @@ -74,15 +99,12 @@ fun ObserveCurrentSessionAnalyticsUseCase( currentIdentifierResult != previousAnalyticsResult && currentResult?.identifier != previousResult?.identifier }, - userDataStoreProvider.getOrCreate(userId).isAnonymousUsageDataEnabled() + userDataStoreProvider.getOrCreate(userId).isAnonymousUsageDataEnabled(), ) { analyticsIdentifierResult, enabled -> previousAnalyticsResult = analyticsIdentifierResult val isProdBackend = when (val serverConfig = currentBackend(userId)) { - is SelfServerConfigUseCase.Result.Success -> - serverConfig.serverLinks.links.api == ServerConfig.PRODUCTION.api - || serverConfig.serverLinks.links.api == ServerConfig.STAGING.api - + is SelfServerConfigUseCase.Result.Success -> serverConfig.serverLinks.isHostValidForAnalytics() is SelfServerConfigUseCase.Result.Failure -> false } @@ -125,6 +147,6 @@ fun ObserveCurrentSessionAnalyticsUseCase( ) ) } - } - .distinctUntilChanged() + }.distinctUntilChanged() + } } diff --git a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt index cfe7e5b902b..71e2fd2b425 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -55,6 +56,8 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex private val APP_LOCK_SOURCE = intPreferencesKey("app_lock_source") private val ENTER_TO_SENT = booleanPreferencesKey("enter_to_sent") private val WIRE_CELLS = booleanPreferencesKey("wire_cells") + private val ANONYMOUS_REGISTRATION_TRACK_ID = stringPreferencesKey("anonymous_registration_track_id") + private val IS_ANONYMOUS_REGISTRATION_ENABLED = booleanPreferencesKey("is_anonymous_registration_enabled") val APP_THEME_OPTION = stringPreferencesKey("app_theme_option") val RECORD_AUDIO_EFFECTS_CHECKBOX = booleanPreferencesKey("record_audio_effects_checkbox") @@ -99,6 +102,39 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex context.dataStore.edit { it[userDoubleTapToastStatusKey(userId)] = shouldShow } } + fun isAnonymousRegistrationEnabled(): Flow = + getBooleanPreference(IS_ANONYMOUS_REGISTRATION_ENABLED, false) + + suspend fun setAnonymousRegistrationEnabled(enabled: Boolean) { + context.dataStore.edit { it[IS_ANONYMOUS_REGISTRATION_ENABLED] = enabled } + } + + suspend fun clearAnonymousRegistrationTrackId() { + context.dataStore.edit { + it.remove(ANONYMOUS_REGISTRATION_TRACK_ID) + } + } + + private suspend fun setAnonymousRegistrationTrackId(trackId: String) { + context.dataStore.edit { + it[ANONYMOUS_REGISTRATION_TRACK_ID] = trackId + } + } + + suspend fun getAnonymousRegistrationTrackId(): String? { + return context.dataStore.data.firstOrNull()?.get(ANONYMOUS_REGISTRATION_TRACK_ID) + } + + suspend fun getOrCreateAnonymousRegistrationTrackId(): String { + val trackId = context.dataStore.data.first()[ANONYMOUS_REGISTRATION_TRACK_ID] + if (trackId.isNullOrBlank()) { + val newTrackId = UUID.randomUUID().toString() + setAnonymousRegistrationTrackId(newTrackId) + return newTrackId + } + return trackId + } + suspend fun getShouldShowDoubleTapToast(userId: String): Boolean = getBooleanPreference(userDoubleTapToastStatusKey(userId), true).first() diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModel.kt index 29bd68342a5..feac6096543 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModel.kt @@ -24,6 +24,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.wire.android.analytics.FinalizeRegistrationAnalyticsMetadataUseCase +import com.wire.android.feature.analytics.AnonymousAnalyticsManager +import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.ui.authentication.create.common.handle.HandleUpdateErrorState import com.wire.android.ui.common.textfield.textAsFlow import com.wire.kalium.logic.feature.auth.ValidateUserHandleResult @@ -39,7 +42,9 @@ import javax.inject.Inject @HiltViewModel class CreateAccountUsernameViewModel @Inject constructor( private val validateUserHandleUseCase: ValidateUserHandleUseCase, - private val setUserHandleUseCase: SetUserHandleUseCase + private val setUserHandleUseCase: SetUserHandleUseCase, + private val anonymousAnalyticsManager: AnonymousAnalyticsManager, + private val finalizeRegistrationAnalyticsMetadata: FinalizeRegistrationAnalyticsMetadataUseCase ) : ViewModel() { val textState: TextFieldState = TextFieldState() @@ -48,6 +53,7 @@ class CreateAccountUsernameViewModel @Inject constructor( init { viewModelScope.launch { + anonymousAnalyticsManager.sendEvent(AnalyticsEvent.RegistrationPersonalAccount.Username) textState.textAsFlow() .dropWhile { it.isEmpty() } // ignore first empty value to not show the error before the user typed anything .collectLatest { newHandle -> @@ -79,7 +85,11 @@ class CreateAccountUsernameViewModel @Inject constructor( is SetUserHandleResult.Failure.Generic -> HandleUpdateErrorState.DialogError.GenericError(result.error) SetUserHandleResult.Failure.HandleExists -> HandleUpdateErrorState.TextFieldError.UsernameTakenError SetUserHandleResult.Failure.InvalidHandle -> HandleUpdateErrorState.TextFieldError.UsernameInvalidError - SetUserHandleResult.Success -> HandleUpdateErrorState.None + SetUserHandleResult.Success -> { + anonymousAnalyticsManager.sendEvent(AnalyticsEvent.RegistrationPersonalAccount.CreationCompleted) + finalizeRegistrationAnalyticsMetadata() + HandleUpdateErrorState.None + } } state = state.copy( loading = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewModel.kt index 1417252ad1d..680010bf7d6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/code/CreateAccountVerificationCodeViewModel.kt @@ -29,6 +29,8 @@ import com.wire.android.BuildConfig import com.wire.android.config.orDefault import com.wire.android.di.ClientScopeProvider import com.wire.android.di.KaliumCoreLogic +import com.wire.android.feature.analytics.AnonymousAnalyticsManager +import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs import com.wire.android.ui.common.textfield.textAsFlow import com.wire.android.ui.navArgs @@ -53,6 +55,7 @@ class CreateAccountVerificationCodeViewModel @Inject constructor( savedStateHandle: SavedStateHandle, @KaliumCoreLogic private val coreLogic: CoreLogic, private val addAuthenticatedUser: AddAuthenticatedUserUseCase, + private val anonymousAnalyticsManager: AnonymousAnalyticsManager, private val clientScopeProviderFactory: ClientScopeProvider.Factory, ) : ViewModel() { @@ -67,6 +70,7 @@ class CreateAccountVerificationCodeViewModel @Inject constructor( init { viewModelScope.launch { + anonymousAnalyticsManager.sendEvent(AnalyticsEvent.RegistrationPersonalAccount.CodeVerification) codeTextState.textAsFlow().collectLatest { if (it.length == codeState.codeLength) onCodeContinue() } @@ -147,6 +151,7 @@ class CreateAccountVerificationCodeViewModel @Inject constructor( val registerResult = authScope.registerScope.register(registerParam).let { when (it) { is RegisterResult.Failure -> { + anonymousAnalyticsManager.sendEvent(AnalyticsEvent.RegistrationPersonalAccount.CodeVerificationFailed) updateCodeErrorState(it.toCodeError()) return@launch } diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt index 20cd353ba21..44cee8a89fd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailScreen.kt @@ -85,6 +85,7 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper import com.wire.android.util.EMPTY +import com.wire.android.util.isHostValidForAnalytics import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.configuration.server.ServerConfig @@ -275,12 +276,14 @@ private fun AccountDetailsContent( autoFill = false, ) - Row(modifier = Modifier.padding(end = MaterialTheme.wireDimensions.spacing16x)) { - WireCheckbox( - checked = state.privacyPolicyAccepted, - onCheckedChange = onPrivacyPolicyAccepted, - ) - WirePrivacyPolicyLink() + if (serverConfig.isHostValidForAnalytics()) { + Row(modifier = Modifier.padding(end = MaterialTheme.wireDimensions.spacing16x)) { + WireCheckbox( + checked = state.privacyPolicyAccepted, + onCheckedChange = onPrivacyPolicyAccepted, + ) + WirePrivacyPolicyLink() + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModel.kt index 4f2b7f156c7..6d6dd61d9c5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModel.kt @@ -25,7 +25,10 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.config.orDefault +import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.KaliumCoreLogic +import com.wire.android.feature.analytics.AnonymousAnalyticsManager +import com.wire.android.feature.analytics.model.AnalyticsEvent.RegistrationPersonalAccount import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs import com.wire.android.ui.common.textfield.textAsFlow import com.wire.android.ui.navArgs @@ -36,20 +39,25 @@ import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.register.RequestActivationCodeResult import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class CreateAccountDataDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val validatePassword: ValidatePasswordUseCase, private val validateEmail: ValidateEmailUseCase, + private val anonymousAnalyticsManager: AnonymousAnalyticsManager, + private val globalDataStore: GlobalDataStore, @KaliumCoreLogic private val coreLogic: CoreLogic, ) : ViewModel() { val createAccountNavArgs: CreateAccountDataNavArgs = savedStateHandle.navArgs() + private var withPasswordTries = false val emailTextState: TextFieldState = TextFieldState(createAccountNavArgs.userRegistrationInfo.email) val nameTextState: TextFieldState = TextFieldState() val passwordTextState: TextFieldState = TextFieldState() @@ -82,6 +90,7 @@ class CreateAccountDataDetailViewModel @Inject constructor( private fun onEmailContinue() { detailsState = detailsState.copy(loading = true, continueEnabled = false) viewModelScope.launch { + delay(ANALYTICS_INIT_WARMUP_THRESHOLD) val email = emailTextState.text.toString().trim().lowercase() val emailError = when (validateEmail(email)) { true -> CreateAccountDataDetailViewState.DetailsError.None @@ -93,12 +102,28 @@ class CreateAccountDataDetailViewModel @Inject constructor( termsDialogVisible = !detailsState.termsAccepted && emailError is CreateAccountDataDetailViewState.DetailsError.None, error = emailError ) - if (detailsState.termsAccepted) onTermsAccept() + + anonymousAnalyticsManager.sendEvent(RegistrationPersonalAccount.AccountSetup(withPasswordTries)) + when { + detailsState.termsAccepted -> { + onTermsAccept() + } + + else -> { + anonymousAnalyticsManager.sendEvent(RegistrationPersonalAccount.TermsOfUseDialog) + } + } }.invokeOnCompletion { detailsState = detailsState.copy(loading = false) } } + private fun updateTrackingStatusBasedOnPrivacyPolicyAccepted() { + viewModelScope.launch { + globalDataStore.setAnonymousRegistrationEnabled(detailsState.privacyPolicyAccepted) + } + } + fun onTermsAccept() { detailsState = detailsState.copy(loading = true, continueEnabled = false, termsDialogVisible = false, termsAccepted = true) viewModelScope.launch { @@ -138,6 +163,7 @@ class CreateAccountDataDetailViewModel @Inject constructor( fun onDetailsContinue() { detailsState = detailsState.copy(loading = true, continueEnabled = false) viewModelScope.launch { + updateTrackingStatusBasedOnPrivacyPolicyAccepted() val detailsError = when { !validatePassword(passwordTextState.text.toString()).isValid -> CreateAccountDataDetailViewState.DetailsError.PasswordError.InvalidPasswordError @@ -154,6 +180,8 @@ class CreateAccountDataDetailViewModel @Inject constructor( ) if (detailsState.error is CreateAccountDataDetailViewState.DetailsError.None) { onEmailContinue() + } else { + withPasswordTries = true } } } @@ -189,4 +217,8 @@ class CreateAccountDataDetailViewModel @Inject constructor( is RequestActivationCodeResult.Success -> CreateAccountDataDetailViewState.DetailsError.None } + + private companion object { + val ANALYTICS_INIT_WARMUP_THRESHOLD = 1.seconds + } } diff --git a/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt b/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt index ae3a1e7213a..b5c9d558e11 100644 --- a/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt @@ -32,9 +32,13 @@ import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl import com.wire.android.navigation.getBaseRoute import com.wire.android.navigation.toDestination import com.wire.android.ui.destinations.ConversationScreenDestination +import com.wire.android.ui.destinations.CreateAccountDataDetailScreenDestination import com.wire.android.ui.destinations.CreateAccountDetailsScreenDestination import com.wire.android.ui.destinations.CreateAccountEmailScreenDestination +import com.wire.android.ui.destinations.CreateAccountSelectorScreenDestination import com.wire.android.ui.destinations.CreateAccountSummaryScreenDestination +import com.wire.android.ui.destinations.CreateAccountUsernameScreenDestination +import com.wire.android.ui.destinations.CreateAccountVerificationCodeScreenDestination import com.wire.android.ui.destinations.CreatePersonalAccountOverviewScreenDestination import com.wire.android.ui.destinations.CreateTeamAccountOverviewScreenDestination import com.wire.android.ui.destinations.E2EIEnrollmentScreenDestination @@ -255,6 +259,10 @@ sealed class CurrentScreen { is E2EIEnrollmentScreenDestination, is E2eiCertificateDetailsScreenDestination, is RegisterDeviceScreenDestination, + is CreateAccountUsernameScreenDestination, + is CreateAccountVerificationCodeScreenDestination, + is CreateAccountDataDetailScreenDestination, + is CreateAccountSelectorScreenDestination, is RemoveDeviceScreenDestination -> AuthRelated(destination.baseRoute) else -> SomeOther(destination?.baseRoute) diff --git a/app/src/main/kotlin/com/wire/android/util/ServerConfigExtensions.kt b/app/src/main/kotlin/com/wire/android/util/ServerConfigExtensions.kt new file mode 100644 index 00000000000..731b6d2d4c2 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/ServerConfigExtensions.kt @@ -0,0 +1,35 @@ +/* + * Wire + * Copyright (C) 2025 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.util + +import com.wire.kalium.logic.configuration.server.ServerConfig + +/** + * Checks if a [ServerConfig] is a valid environment for analytics, returning true in case can be enabled. + */ +fun ServerConfig.isHostValidForAnalytics(): Boolean { + return this.links.isHostValidForAnalytics() +} + +/** + * Checks if a [ServerConfig.Links] is a valid environment for analytics, returning true in case can be enabled. + */ +fun ServerConfig.Links.isHostValidForAnalytics(): Boolean { + return this.api == ServerConfig.PRODUCTION.api + || this.api == ServerConfig.STAGING.api +} diff --git a/app/src/test/kotlin/com/wire/android/analytics/FinalizeRegistrationAnalyticsMetadataUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/analytics/FinalizeRegistrationAnalyticsMetadataUseCaseTest.kt new file mode 100644 index 00000000000..65cd3275d1d --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/analytics/FinalizeRegistrationAnalyticsMetadataUseCaseTest.kt @@ -0,0 +1,93 @@ +/* + * Wire + * Copyright (C) 2025 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.GlobalDataStore +import com.wire.android.framework.TestUser +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.feature.UserSessionScope +import com.wire.kalium.logic.feature.analytics.SetNewUserTrackingIdentifierUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FinalizeRegistrationAnalyticsMetadataUseCaseTest { + + @Test + fun `givenAnonymousDataIsDisabledForRegistration, whenFinalizingRegistration, thenDoNothing`() = runTest { + val (arrangement, useCase) = Arrangement() + .withIsAnonymousRegistrationEnabledResult(false) + .arrange() + + useCase() + + coVerify(exactly = 0) { arrangement.coreLogic.getSessionScope(any()) } + coVerify(exactly = 0) { arrangement.globalDataStore.clearAnonymousRegistrationTrackId() } + coVerify(exactly = 0) { arrangement.globalDataStore.setAnonymousRegistrationEnabled(any()) } + } + + @Test + fun `givenAnonymousDataIsEnabledForRegistration, whenFinalizingRegistration, then cleanup metadata`() = runTest { + val (arrangement, useCase) = Arrangement() + .withIsAnonymousRegistrationEnabledResult(true) + .withAnonymousRegistrationTrackId("trackId") + .arrange() + + useCase() + + coVerify(exactly = 1) { arrangement.coreLogic.getSessionScope(any()) } + coVerify(exactly = 1) { arrangement.globalDataStore.clearAnonymousRegistrationTrackId() } + coVerify(exactly = 1) { arrangement.globalDataStore.setAnonymousRegistrationEnabled(eq(false)) } + } + + private class Arrangement { + + @MockK + lateinit var globalDataStore: GlobalDataStore + + @MockK + lateinit var coreLogic: CoreLogic + + @MockK + lateinit var userSessionScope: UserSessionScope + + @MockK + lateinit var setNewUserTrackingIdentifierUseCase: SetNewUserTrackingIdentifierUseCase + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { coreLogic.getSessionScope(any()) } returns userSessionScope + every { coreLogic.getSessionScope(any()).setNewUserTrackingIdentifier } returns setNewUserTrackingIdentifierUseCase + } + + fun withIsAnonymousRegistrationEnabledResult(result: Boolean) = apply { + every { globalDataStore.isAnonymousRegistrationEnabled() } returns flowOf(result) + } + + fun withAnonymousRegistrationTrackId(trackId: String) = apply { + coEvery { globalDataStore.getAnonymousRegistrationTrackId() } returns trackId + } + + fun arrange() = this to FinalizeRegistrationAnalyticsMetadataUseCase(globalDataStore, TestUser.SELF_USER_ID, coreLogic) + } +} diff --git a/app/src/test/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCaseTest.kt index c363187cc6e..168a9cc6bc3 100644 --- a/app/src/test/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/analytics/ObserveCurrentSessionAnalyticsUseCaseTest.kt @@ -19,6 +19,7 @@ package com.wire.android.analytics import app.cash.turbine.test import com.wire.android.assertIs +import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStore import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.feature.analytics.model.AnalyticsProfileProperties @@ -217,6 +218,25 @@ class ObserveCurrentSessionAnalyticsUseCaseTest { } } + @Test + fun givenRegistrationIdIsEnabled_whenObservingCurrentSessionAnalytics_thenEnableAnalyticsResultForRegistrationIsReturned() = runTest { + // given + val (_, useCase) = Arrangement().apply { + setCurrentSession(CurrentSessionResult.Failure.SessionNotFound) + withIsAnonymousRegistrationEnabledResult(true) + withAnonymousRegistrationTrackingId("trackId") + }.arrange() + + // when + useCase.invoke().test { + // then + val item = awaitItem() + assertIs(item.identifierResult) + assertEquals(false, item.profileProperties().isTeamMember) + assertEquals(null, item.manager) + } + } + private fun assertAnalyticsProfileProperties(expected: AnalyticsContactsData, actual: AnalyticsProfileProperties) { assertEquals(expected.teamId, actual.teamId) assertEquals(expected.isTeamMember, actual.isTeamMember) @@ -236,6 +256,9 @@ class ObserveCurrentSessionAnalyticsUseCaseTest { @MockK private lateinit var analyticsIdentifierManager: AnalyticsIdentifierManager + @MockK + lateinit var globalDataStore: GlobalDataStore + private val currentSessionChannel = Channel(Channel.UNLIMITED) private val analyticsTrackingIdentifierStatusChannel = Channel(Channel.UNLIMITED) @@ -251,12 +274,21 @@ class ObserveCurrentSessionAnalyticsUseCaseTest { init { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) + withIsAnonymousRegistrationEnabledResult(false) } suspend fun setCurrentSession(result: CurrentSessionResult) { currentSessionChannel.send(result) } + fun withAnonymousRegistrationTrackingId(trackId: String) = apply { + coEvery { globalDataStore.getOrCreateAnonymousRegistrationTrackId() } returns trackId + } + + fun withIsAnonymousRegistrationEnabledResult(result: Boolean) = apply { + every { globalDataStore.isAnonymousRegistrationEnabled() } returns flowOf(result) + } + fun setAnalyticsContactsData(userId: UserId, data: AnalyticsContactsData) { analyticsContactsData[userId] = data } @@ -286,7 +318,8 @@ class ObserveCurrentSessionAnalyticsUseCaseTest { userDataStoreProvider = userDataStoreProvider, currentBackend = { selfServerConfigChannel.receive() - } + }, + globalDataStore = globalDataStore ) fun arrange() = this to useCase diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModelTest.kt index 1cfc71ba8e3..2d4463556cf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/create/username/CreateAccountUsernameViewModelTest.kt @@ -20,9 +20,12 @@ package com.wire.android.ui.authentication.create.username import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.wire.android.analytics.FinalizeRegistrationAnalyticsMetadataUseCase import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.config.mockUri +import com.wire.android.feature.analytics.AnonymousAnalyticsManager +import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.ui.authentication.create.common.handle.HandleUpdateErrorState import com.wire.android.util.EMPTY import com.wire.kalium.common.error.NetworkFailure @@ -33,6 +36,7 @@ import com.wire.kalium.logic.feature.user.SetUserHandleUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -101,6 +105,10 @@ class CreateAccountUsernameViewModelTest { advanceUntilIdle() verify(exactly = 1) { arrangement.validateUserHandleUseCase.invoke(username) } coVerify(exactly = 1) { arrangement.setUserHandleUseCase.invoke(username) } + coVerify(exactly = 1) { arrangement.finalizeRegistrationAnalyticsMetadataUseCase.invoke() } + verify(exactly = 1) { + arrangement.anonymousAnalyticsManager.sendEvent(eq(AnalyticsEvent.RegistrationPersonalAccount.CreationCompleted)) + } createAccountUsernameViewModel.state.success shouldBeEqualTo true } @@ -195,18 +203,36 @@ class CreateAccountUsernameViewModelTest { @MockK lateinit var setUserHandleUseCase: SetUserHandleUseCase - private val viewModel by lazy { CreateAccountUsernameViewModel(validateUserHandleUseCase, setUserHandleUseCase) } + @MockK + lateinit var anonymousAnalyticsManager: AnonymousAnalyticsManager + + @MockK + lateinit var finalizeRegistrationAnalyticsMetadataUseCase: FinalizeRegistrationAnalyticsMetadataUseCase + + private val viewModel by lazy { + CreateAccountUsernameViewModel( + validateUserHandleUseCase, + setUserHandleUseCase, + anonymousAnalyticsManager, + finalizeRegistrationAnalyticsMetadataUseCase + ) + } init { MockKAnnotations.init(this, relaxUnitFun = true) mockUri() + coEvery { finalizeRegistrationAnalyticsMetadataUseCase() } returns Unit + every { anonymousAnalyticsManager.sendEvent(any()) } returns Unit } + fun withValidateHandleResult(result: ValidateUserHandleResult, forSpecificHandle: String? = null) = apply { coEvery { validateUserHandleUseCase(forSpecificHandle?.let { eq(it) } ?: any()) } returns result } + fun withSetUserHandle(result: SetUserHandleResult) = apply { coEvery { setUserHandleUseCase.invoke(any()) } returns result } + fun arrange() = this to viewModel } } diff --git a/app/src/test/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModelTest.kt index 75fa78ace61..c431e5ca3e4 100644 --- a/app/src/test/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/registration/details/CreateAccountDataDetailViewModelTest.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.config.SnapshotExtension +import com.wire.android.datastore.GlobalDataStore +import com.wire.android.feature.analytics.AnonymousAnalyticsManager +import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.ui.authentication.create.common.CreateAccountDataNavArgs import com.wire.android.ui.authentication.create.common.UserRegistrationInfo import com.wire.android.ui.navArgs @@ -21,6 +24,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -47,7 +51,6 @@ class CreateAccountDataDetailViewModelTest { coVerify(exactly = 0) { arrangement.validateEmailUseCase(any()) } } - @Test fun `given passwords do not match, when executing, then show error`() = runTest { val (arrangement, viewModel) = Arrangement() .withValidatePasswordResult(ValidatePasswordResult.Valid) @@ -63,6 +66,40 @@ class CreateAccountDataDetailViewModelTest { ) assertEquals(false, viewModel.detailsState.success) coVerify(exactly = 0) { arrangement.validateEmailUseCase(any()) } + verify(exactly = 1) { + arrangement.anonymousAnalyticsManager.sendEvent(eq(AnalyticsEvent.RegistrationPersonalAccount.AccountSetup(true))) + } + } + + @Test + fun `given passwords do not match, when executing and fixed, then track error`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withValidatePasswordResult(ValidatePasswordResult.Valid) + .withValidateEmailResult(true) + .withActivationCodeResult(RequestActivationCodeResult.Success) + .arrange() + viewModel.passwordTextState.setTextAndPlaceCursorAtEnd("password") + viewModel.confirmPasswordTextState.setTextAndPlaceCursorAtEnd("different-password") + + viewModel.onDetailsContinue() + advanceUntilIdle() + + // fix the password then continue + viewModel.confirmPasswordTextState.setTextAndPlaceCursorAtEnd("password") + viewModel.onDetailsContinue() + advanceUntilIdle() + + assertInstanceOf(viewModel.detailsState.error) + assertEquals(false, viewModel.detailsState.success) + coVerify(exactly = 1) { arrangement.validateEmailUseCase(any()) } + verify(exactly = 1) { + arrangement.anonymousAnalyticsManager.sendEvent(eq(AnalyticsEvent.RegistrationPersonalAccount.TermsOfUseDialog)) + } + verify(exactly = 1) { + arrangement.anonymousAnalyticsManager.sendEvent(eq(AnalyticsEvent.RegistrationPersonalAccount.AccountSetup(true))) + } + assertInstanceOf(viewModel.detailsState.error) + assertEquals(true, viewModel.detailsState.termsDialogVisible) } @Test @@ -81,6 +118,12 @@ class CreateAccountDataDetailViewModelTest { assertInstanceOf(viewModel.detailsState.error) assertEquals(false, viewModel.detailsState.success) coVerify(exactly = 1) { arrangement.validateEmailUseCase(any()) } + verify(exactly = 1) { + arrangement.anonymousAnalyticsManager.sendEvent(eq(AnalyticsEvent.RegistrationPersonalAccount.TermsOfUseDialog)) + } + verify(exactly = 1) { + arrangement.anonymousAnalyticsManager.sendEvent(eq(AnalyticsEvent.RegistrationPersonalAccount.AccountSetup(false))) + } assertInstanceOf(viewModel.detailsState.error) assertEquals(true, viewModel.detailsState.termsDialogVisible) } @@ -139,6 +182,12 @@ class CreateAccountDataDetailViewModelTest { @MockK lateinit var validatePasswordUseCase: ValidatePasswordUseCase + @MockK + lateinit var anonymousAnalyticsManager: AnonymousAnalyticsManager + + @MockK + lateinit var globalDataStore: GlobalDataStore + init { MockKAnnotations.init(this, relaxUnitFun = true) every { savedStateHandle.navArgs() } returns @@ -153,6 +202,7 @@ class CreateAccountDataDetailViewModelTest { coEvery { autoVersionAuthScopeUseCase(any()) } returns AutoVersionAuthScopeUseCase.Result.Success(authenticationScope) coEvery { authenticationScope.registerScope.requestActivationCode } returns requestActivationCodeUseCase + coEvery { anonymousAnalyticsManager.sendEvent(any()) } returns Unit } fun withActivationCodeResult(result: RequestActivationCodeResult) = apply { @@ -171,7 +221,9 @@ class CreateAccountDataDetailViewModelTest { savedStateHandle = savedStateHandle, validateEmail = validateEmailUseCase, validatePassword = validatePasswordUseCase, - coreLogic = coreLogic + coreLogic = coreLogic, + anonymousAnalyticsManager = anonymousAnalyticsManager, + globalDataStore = globalDataStore ) } } 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 f4c144e2728..2e57bd029f0 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 @@ -171,6 +171,14 @@ object AnonymousAnalyticsManagerImpl : AnonymousAnalyticsManager { } is AnalyticsIdentifierResult.Disabled -> {} + is AnalyticsIdentifierResult.RegistrationIdentifier -> { + anonymousAnalyticsRecorder?.setTrackingIdentifierWithoutMerge( + identifier = analyticsIdentifierResult.identifier, + shouldPropagateIdentifier = false, + analyticsProfileProperties = analyticsProfileProperties, + propagateIdentifier = {} + ) + } } } 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 60f3d7a1908..b5e1c501301 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 @@ -48,6 +48,10 @@ import com.wire.android.feature.analytics.model.AnalyticsEventConstants.PERSONAL import com.wire.android.feature.analytics.model.AnalyticsEventConstants.PERSONAL_TO_TEAM_FLOW_TEAM_PLAN_EVENT import com.wire.android.feature.analytics.model.AnalyticsEventConstants.QR_CODE_SEGMENTATION_USER_TYPE_PERSONAL import com.wire.android.feature.analytics.model.AnalyticsEventConstants.QR_CODE_SEGMENTATION_USER_TYPE_TEAM +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.REGISTRATION_ACCOUNT_CODE_VERIFICATION_EVENT +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.REGISTRATION_ACCOUNT_CODE_VERIFICATION_FAILED_EVENT +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.REGISTRATION_ACCOUNT_SETUP_PASSWORD_TRIES_SEGMENTATION +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.REGISTRATION_ACCOUNT_TOU_EVENT import com.wire.kalium.logic.data.call.RecentlyEndedCallMetadata import com.wire.kalium.logic.data.conversation.Conversation @@ -363,6 +367,38 @@ interface AnalyticsEvent { override val key: String = PERSONAL_TO_TEAM_FLOW_COMPLETED_EVENT } } + + sealed interface RegistrationPersonalAccount : AnalyticsEvent { + override fun toSegmentation(): Map = mapOf(IS_TEAM_MEMBER to false) + + data class AccountSetup(val withPasswordTries: Boolean) : RegistrationPersonalAccount { + override val key: String = AnalyticsEventConstants.REGISTRATION_ACCOUNT_SETUP_EVENT + override fun toSegmentation(): Map = mapOf( + IS_TEAM_MEMBER to false, + REGISTRATION_ACCOUNT_SETUP_PASSWORD_TRIES_SEGMENTATION to withPasswordTries + ) + } + + data object TermsOfUseDialog : RegistrationPersonalAccount { + override val key: String = REGISTRATION_ACCOUNT_TOU_EVENT + } + + data object CodeVerification : RegistrationPersonalAccount { + override val key: String = REGISTRATION_ACCOUNT_CODE_VERIFICATION_EVENT + } + + data object CodeVerificationFailed : RegistrationPersonalAccount { + override val key: String = REGISTRATION_ACCOUNT_CODE_VERIFICATION_FAILED_EVENT + } + + data object Username : RegistrationPersonalAccount { + override val key: String = AnalyticsEventConstants.REGISTRATION_ACCOUNT_USERNAME_EVENT + } + + data object CreationCompleted : RegistrationPersonalAccount { + override val key: String = AnalyticsEventConstants.REGISTRATION_ACCOUNT_COMPLETION_EVENT + } + } } object AnalyticsEventConstants { @@ -458,4 +494,15 @@ object AnalyticsEventConstants { const val PERSONAL_TO_TEAM_FLOW_CONFIRM_EVENT = "user.personal-to-team-flow-confirm-3" const val PERSONAL_TO_TEAM_FLOW_COMPLETED_EVENT = "user.personal-to-team-flow-completed-4" const val MIGRATION_DOT_ACTIVE = "migration_dot_active" + + /** + * New registration - Personal account creation + */ + const val REGISTRATION_ACCOUNT_SETUP_EVENT = "registration.account_setup_screen_1" + const val REGISTRATION_ACCOUNT_SETUP_PASSWORD_TRIES_SEGMENTATION = "multiple_password_tries" + const val REGISTRATION_ACCOUNT_TOU_EVENT = "registration.account_ToU_screen_1.5" + const val REGISTRATION_ACCOUNT_CODE_VERIFICATION_EVENT = "registration.account_verification_screen_2" + const val REGISTRATION_ACCOUNT_CODE_VERIFICATION_FAILED_EVENT = "registration.account_verification_failed_screen_2.5" + const val REGISTRATION_ACCOUNT_USERNAME_EVENT = "registration.account_username_screen_3" + const val REGISTRATION_ACCOUNT_COMPLETION_EVENT = "registration.account_completion_screen_4" } diff --git a/kalium b/kalium index dcb21c6d569..89fb293d3ae 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit dcb21c6d569463b07d6570392f0381ae492ff54e +Subproject commit 89fb293d3ae78d14a8e053af667ccb7e86fe842d