diff --git a/changelog.d/627.feature b/changelog.d/627.feature new file mode 100644 index 0000000000..0b875fc536 --- /dev/null +++ b/changelog.d/627.feature @@ -0,0 +1 @@ +Add analytics events for room creation diff --git a/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt b/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt index 47eba919ed..3d969567f7 100644 --- a/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt +++ b/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt @@ -31,6 +31,7 @@ class FakeAnalyticsService( private var isEnabledFlow = MutableStateFlow(isEnabled) private var didAskUserConsentFlow = MutableStateFlow(didAskUserConsent) + var capturedEvents = mutableListOf() override fun getAvailableAnalyticsProviders(): List = emptyList() @@ -55,6 +56,7 @@ class FakeAnalyticsService( } override fun capture(event: VectorAnalyticsEvent) { + capturedEvents += event } override fun screen(screen: VectorAnalyticsScreen) { diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index f203501315..fd7a6d6f5e 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.usersearch.impl) + implementation(projects.services.analytics.api) implementation(libs.coil.compose) api(projects.features.createroom.api) @@ -59,6 +60,7 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(libs.test.robolectric) + testImplementation(projects.features.analytics.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediaupload.test) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt index 2ac5b8691a..b09863b205 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt @@ -18,23 +18,35 @@ package io.element.android.features.createroom.impl.configureroom import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.anvilannotations.ContributesNode import io.element.android.features.createroom.impl.di.CreateRoomScope import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.services.analytics.api.AnalyticsService @ContributesNode(CreateRoomScope::class) class ConfigureRoomNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val presenter: ConfigureRoomPresenter, + private val analyticsService: AnalyticsService, ) : Node(buildContext, plugins = plugins) { + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreateRoom)) + } + ) + } + interface Callback : Plugin { fun onCreateRoomSuccess(roomId: RoomId) } @@ -50,7 +62,7 @@ class ConfigureRoomNode @AssistedInject constructor( state = state, modifier = modifier, onBackPressed = this::navigateUp, - onRoomCreated = this::onRoomCreated + onRoomCreated = this::onRoomCreated, ) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index f2a03ca2c0..8143114573 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import im.vector.app.features.analytics.plan.CreatedRoom import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.libraries.architecture.Async @@ -39,6 +40,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -49,6 +51,7 @@ class ConfigureRoomPresenter @Inject constructor( private val matrixClient: MatrixClient, private val mediaPickerProvider: PickerProvider, private val mediaPreProcessor: MediaPreProcessor, + private val analyticsService: AnalyticsService, ) : Presenter { @Composable @@ -124,7 +127,10 @@ class ConfigureRoomPresenter @Inject constructor( avatar = avatarUrl, ) matrixClient.createRoom(params).getOrThrow() - .also { dataStore.clearCachedData() } + .also { + dataStore.clearCachedData() + analyticsService.capture(CreatedRoom(isDM = false)) + } }.execute(createRoomAction) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt index 6b5ac667c5..4089be0fa2 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt @@ -20,12 +20,14 @@ import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.anvilannotations.ContributesNode import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.core.meta.BuildMeta @@ -34,6 +36,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.ui.strings.R +import io.element.android.services.analytics.api.AnalyticsService import timber.log.Timber @ContributesNode(SessionScope::class) @@ -43,6 +46,7 @@ class CreateRoomRootNode @AssistedInject constructor( private val presenter: CreateRoomRootPresenter, private val matrixClient: MatrixClient, private val buildMeta: BuildMeta, + private val analyticsService: AnalyticsService, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { @@ -60,6 +64,12 @@ class CreateRoomRootNode @AssistedInject constructor( } } + init { + lifecycle.subscribe( + onResume = { analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.StartChat)) } + ) + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() @@ -70,7 +80,7 @@ class CreateRoomRootNode @AssistedInject constructor( onClosePressed = this::navigateUp, onNewRoomClicked = callback::onCreateNewRoom, onOpenDM = callback::onStartChatSuccess, - onInviteFriendsClicked = { invitePeople(context) } + onInviteFriendsClicked = { invitePeople(context) }, ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt index 7b5c6b7cf1..9e9b3b28a4 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import im.vector.app.features.analytics.plan.CreatedRoom import io.element.android.features.createroom.impl.userlist.SelectionMode import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListPresenter @@ -32,6 +33,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.usersearch.api.UserRepository +import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -41,6 +43,7 @@ class CreateRoomRootPresenter @Inject constructor( private val userRepository: UserRepository, private val userListDataStore: UserListDataStore, private val matrixClient: MatrixClient, + private val analyticsService: AnalyticsService, ) : Presenter { private val presenter by lazy { @@ -88,6 +91,7 @@ class CreateRoomRootPresenter @Inject constructor( private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState>) = launch { suspend { matrixClient.createDM(user.userId).getOrThrow() + .also { analyticsService.capture(CreatedRoom(isDM = true)) } }.execute(startDmAction) } } diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt index 736fca9cb4..9b6ac2e067 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt @@ -21,6 +21,8 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.analytics.test.FakeAnalyticsService import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore @@ -62,6 +64,7 @@ class ConfigureRoomPresenterTests { private lateinit var fakeMatrixClient: FakeMatrixClient private lateinit var fakePickerProvider: FakePickerProvider private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor + private lateinit var fakeAnalyticsService: FakeAnalyticsService @Before fun setup() { @@ -70,11 +73,13 @@ class ConfigureRoomPresenterTests { createRoomDataStore = CreateRoomDataStore(userListDataStore) fakePickerProvider = FakePickerProvider() fakeMediaPreProcessor = FakeMediaPreProcessor() + fakeAnalyticsService = FakeAnalyticsService() presenter = ConfigureRoomPresenter( dataStore = createRoomDataStore, matrixClient = fakeMatrixClient, mediaPickerProvider = fakePickerProvider, mediaPreProcessor = fakeMediaPreProcessor, + analyticsService = fakeAnalyticsService, ) mockkStatic(File::readBytes) @@ -214,6 +219,25 @@ class ConfigureRoomPresenterTests { } } + @Test + fun `present - record analytics when creating room`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val createRoomResult = Result.success(RoomId("!createRoomResult:domain")) + + fakeMatrixClient.givenCreateRoomResult(createRoomResult) + + initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) + skipItems(2) + + val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance().firstOrNull() + assertThat(analyticsEvent).isNotNull() + assertThat(analyticsEvent?.isDM).isFalse() + } + } + @Test fun `present - trigger create room with upload error and retry`() = runTest { moleculeFlow(RecompositionClock.Immediate) { @@ -229,6 +253,7 @@ class ConfigureRoomPresenterTests { assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java) val stateAfterCreateRoom = awaitItem() assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java) + assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance()).isEmpty() fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL)) stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt index 0d0fdcce44..38d563c33c 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt @@ -20,6 +20,8 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.analytics.test.FakeAnalyticsService import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory import io.element.android.features.createroom.impl.userlist.UserListDataStore @@ -43,17 +45,20 @@ class CreateRoomRootPresenterTests { private lateinit var presenter: CreateRoomRootPresenter private lateinit var fakeUserListPresenter: FakeUserListPresenter private lateinit var fakeMatrixClient: FakeMatrixClient + private lateinit var fakeAnalyticsService: FakeAnalyticsService @Before fun setup() { fakeUserListPresenter = FakeUserListPresenter() fakeMatrixClient = FakeMatrixClient() + fakeAnalyticsService = FakeAnalyticsService() userRepository = FakeUserRepository() presenter = CreateRoomRootPresenter( presenterFactory = FakeUserListPresenterFactory(fakeUserListPresenter), userRepository = userRepository, userListDataStore = UserListDataStore(), - matrixClient = fakeMatrixClient + matrixClient = fakeMatrixClient, + analyticsService = fakeAnalyticsService, ) } @@ -87,6 +92,27 @@ class CreateRoomRootPresenterTests { } } + @Test + fun `present - creating a DM records analytics event`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val matrixUser = MatrixUser(UserId("@name:domain")) + val createDmResult = Result.success(RoomId("!createDmResult:domain")) + + fakeMatrixClient.givenFindDmResult(null) + fakeMatrixClient.givenCreateDmResult(createDmResult) + + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + skipItems(2) + + val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance().firstOrNull() + assertThat(analyticsEvent).isNotNull() + assertThat(analyticsEvent?.isDM).isTrue() + } + } + @Test fun `present - trigger retrieve DM action`() = runTest { moleculeFlow(RecompositionClock.Immediate) { @@ -102,6 +128,7 @@ class CreateRoomRootPresenterTests { val stateAfterStartDM = awaitItem() assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java) assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId) + assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance()).isEmpty() } } @@ -124,6 +151,7 @@ class CreateRoomRootPresenterTests { assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) val stateAfterStartDM = awaitItem() assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java) + assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance()).isEmpty() // Cancel stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM) @@ -135,6 +163,7 @@ class CreateRoomRootPresenterTests { assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) val stateAfterSecondAttempt = awaitItem() assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java) + assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance()).isEmpty() // Retry with success fakeMatrixClient.givenCreateDmError(null)