From 66030aeb642458c4236cc904a4ccfe7d507dc556 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 19 Feb 2024 17:36:23 +0100 Subject: [PATCH 1/2] Analytics : add analytics on read status and favorite toggles --- features/roomdetails/impl/build.gradle.kts | 1 + .../roomdetails/impl/RoomDetailsPresenter.kt | 17 +++-- .../roomdetails/RoomDetailsPresenterTests.kt | 12 +++- features/roomlist/impl/build.gradle.kts | 1 + .../roomlist/impl/RoomListPresenter.kt | 62 ++++++++++++------- .../roomlist/impl/RoomListPresenterTests.kt | 21 ++++++- gradle/libs.versions.toml | 2 +- .../api/trackers/AnalyticsTracker.kt | 5 ++ 8 files changed, 91 insertions(+), 30 deletions(-) diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 0f1a139f40..ee28f142ca 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { testImplementation(projects.tests.testutils) testImplementation(projects.features.leaveroom.test) testImplementation(projects.features.createroom.test) + testImplementation(projects.services.analytics.test) ksp(libs.showkase.processor) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 2c663bfdde..fdbdc77910 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.Lifecycle +import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter @@ -46,6 +47,8 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canInvite import io.element.android.libraries.matrix.api.room.powerlevels.canSendState import io.element.android.libraries.matrix.api.room.roomNotificationSettings import io.element.android.libraries.matrix.ui.room.getDirectRoomMember +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -60,6 +63,7 @@ class RoomDetailsPresenter @Inject constructor( private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory, private val leaveRoomPresenter: LeaveRoomPresenter, private val dispatchers: CoroutineDispatchers, + private val analyticsService: AnalyticsService, ) : Presenter { @Composable override fun present(): RoomDetailsState { @@ -124,11 +128,7 @@ class RoomDetailsPresenter @Inject constructor( client.notificationSettingsService().unmuteRoom(room.roomId, room.isEncrypted, room.isOneToOne) } } - is RoomDetailsEvent.SetFavorite -> { - scope.launch { - room.setIsFavorite(event.isFavorite) - } - } + is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite) } } @@ -187,4 +187,11 @@ class RoomDetailsPresenter @Inject constructor( room.updateRoomNotificationSettings() }.launchIn(this) } + + private fun CoroutineScope.setFavorite(isFavorite: Boolean) = launch { + room.setIsFavorite(isFavorite) + .onSuccess { + analyticsService.captureInteraction(Interaction.Name.MobileRoomFavouriteToggle) + } + } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt index 1faccce70e..895ce759f4 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt @@ -22,6 +22,7 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.createroom.test.FakeStartDMAction import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomPresenter @@ -50,6 +51,8 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.FakeLifecycleOwner import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate @@ -77,6 +80,7 @@ class RoomDetailsPresenterTests { leaveRoomPresenter: LeaveRoomPresenter = FakeLeaveRoomPresenter(), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + analyticsService: AnalyticsService = FakeAnalyticsService(), ): RoomDetailsPresenter { val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { @@ -95,6 +99,7 @@ class RoomDetailsPresenterTests { roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory, leaveRoomPresenter = leaveRoomPresenter, dispatchers = dispatchers, + analyticsService = analyticsService, ) } @@ -435,13 +440,18 @@ class RoomDetailsPresenterTests { @Test fun `present - when set is favorite event is emitted, then the action is called`() = runTest { val room = FakeMatrixRoom() - val presenter = createRoomDetailsPresenter(room = room) + val analyticsService = FakeAnalyticsService() + val presenter = createRoomDetailsPresenter(room = room, analyticsService = analyticsService) presenter.test { val initialState = awaitItem() initialState.eventSink(RoomDetailsEvent.SetFavorite(true)) assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true)) initialState.eventSink(RoomDetailsEvent.SetFavorite(false)) assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true, false)) + assertThat(analyticsService.capturedEvents).containsExactly( + Interaction(name = Interaction.Name.MobileRoomFavouriteToggle), + Interaction(name = Interaction.Name.MobileRoomFavouriteToggle) + ) cancelAndIgnoreRemainingEvents() } } diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts index 61f40b53ec..207be1df5b 100644 --- a/features/roomlist/impl/build.gradle.kts +++ b/features/roomlist/impl/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { testImplementation(projects.libraries.permissions.noop) testImplementation(projects.libraries.preferences.test) testImplementation(projects.features.invitelist.test) + testImplementation(projects.services.analytics.test) testImplementation(projects.features.networkmonitor.test) testImplementation(projects.tests.testutils) testImplementation(projects.features.leaveroom.test) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 68ef53703a..488ebdaff0 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.networkmonitor.api.NetworkMonitor @@ -45,12 +46,15 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.getCurrentUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect @@ -78,6 +82,7 @@ class RoomListPresenter @Inject constructor( private val indicatorService: IndicatorService, private val migrationScreenPresenter: MigrationScreenPresenter, private val sessionPreferencesStore: SessionPreferencesStore, + private val analyticsService: AnalyticsService, ) : Presenter { @Composable override fun present(): RoomListState { @@ -145,27 +150,9 @@ class RoomListPresenter @Inject constructor( } is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId)) - is RoomListEvents.SetRoomIsFavorite -> coroutineScope.launch { - client.getRoom(event.roomId)?.use { room -> - room.setIsFavorite(event.isFavorite) - } - } - is RoomListEvents.MarkAsRead -> coroutineScope.launch { - client.getRoom(event.roomId)?.use { room -> - room.setUnreadFlag(isUnread = false) - val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) { - ReceiptType.READ - } else { - ReceiptType.READ_PRIVATE - } - room.markAsRead(receiptType) - } - } - is RoomListEvents.MarkAsUnread -> coroutineScope.launch { - client.getRoom(event.roomId)?.use { room -> - room.setUnreadFlag(isUnread = true) - } - } + is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite) + is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId) + is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId) } } @@ -225,6 +212,39 @@ class RoomListPresenter @Inject constructor( } } + private fun CoroutineScope.setRoomIsFavorite(roomId: RoomId, isFavorite: Boolean) = launch { + client.getRoom(roomId)?.use { room -> + room.setIsFavorite(isFavorite) + .onSuccess { + analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle) + } + } + } + + private fun CoroutineScope.markAsRead(roomId: RoomId) = launch { + client.getRoom(roomId)?.use { room -> + room.setUnreadFlag(isUnread = false) + val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) { + ReceiptType.READ + } else { + ReceiptType.READ_PRIVATE + } + room.markAsRead(receiptType) + .onSuccess { + analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle) + } + } + } + + private fun CoroutineScope.markAsUnread(roomId: RoomId) = launch { + client.getRoom(roomId)?.use { room -> + room.setUnreadFlag(isUnread = true) + .onSuccess { + analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle) + } + } + } + private fun updateVisibleRange(range: IntRange) { if (range.isEmpty()) return val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2 diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index f106b72fb9..2502c0520a 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter @@ -66,6 +67,8 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.testCoroutineDispatchers @@ -474,10 +477,11 @@ class RoomListPresenterTests { fun `present - when set is favorite event is emitted, then the action is called`() = runTest { val scope = CoroutineScope(coroutineContext + SupervisorJob()) val room = FakeMatrixRoom() + val analyticsService = FakeAnalyticsService() val client = FakeMatrixClient().apply { givenGetRoomResult(A_ROOM_ID, room) } - val presenter = createRoomListPresenter(client = client, coroutineScope = scope) + val presenter = createRoomListPresenter(client = client, coroutineScope = scope, analyticsService = analyticsService) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -486,6 +490,10 @@ class RoomListPresenterTests { assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true)) initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, false)) assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true, false)) + assertThat(analyticsService.capturedEvents).containsExactly( + Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle), + Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle) + ) cancelAndIgnoreRemainingEvents() scope.cancel() } @@ -527,11 +535,13 @@ class RoomListPresenterTests { val matrixClient = FakeMatrixClient().apply { givenGetRoomResult(A_ROOM_ID, room) } + val analyticsService = FakeAnalyticsService() val scope = CoroutineScope(coroutineContext + SupervisorJob()) val presenter = createRoomListPresenter( client = matrixClient, coroutineScope = scope, sessionPreferencesStore = sessionPreferencesStore, + analyticsService = analyticsService, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -550,6 +560,11 @@ class RoomListPresenterTests { initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID)) assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ, ReceiptType.READ_PRIVATE)) assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false, true, false)) + assertThat(analyticsService.capturedEvents).containsExactly( + Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle), + Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle), + Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle), + ) cancelAndIgnoreRemainingEvents() scope.cancel() } @@ -572,7 +587,8 @@ class RoomListPresenterTests { migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter( matrixClient = client, migrationScreenStore = InMemoryMigrationScreenStore(), - ) + ), + analyticsService: AnalyticsService = FakeAnalyticsService(), ) = RoomListPresenter( client = client, sessionVerificationService = sessionVerificationService, @@ -599,5 +615,6 @@ class RoomListPresenterTests { ), migrationScreenPresenter = migrationScreenPresenter, sessionPreferencesStore = sessionPreferencesStore, + analyticsService = analyticsService, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09b1b6edb8..8986bedb12 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -174,7 +174,7 @@ kotlinpoet = "com.squareup:kotlinpoet:1.16.0" # Analytics posthog = "com.posthog:posthog-android:3.1.7" sentry = "io.sentry:sentry-android:7.3.0" -matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:aa14cbcdf81af2746d20a71779ec751f971e1d7f" +matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.11.0" # Emojibase matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3" diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt index e37053fbf7..aa88ba0cdd 100644 --- a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt @@ -18,6 +18,7 @@ package io.element.android.services.analyticsproviders.api.trackers import im.vector.app.features.analytics.itf.VectorAnalyticsEvent import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.UserProperties interface AnalyticsTracker { @@ -36,3 +37,7 @@ interface AnalyticsTracker { */ fun updateUserProperties(userProperties: UserProperties) } + +fun AnalyticsTracker.captureInteraction(name: Interaction.Name, type: Interaction.InteractionType? = null) { + capture(Interaction(interactionType = type, name = name)) +} From b814a101b51d304d2596013695f82e2708f51985 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 20 Feb 2024 10:59:13 +0100 Subject: [PATCH 2/2] Fix sample compilation --- samples/minimal/build.gradle.kts | 1 + .../kotlin/io/element/android/samples/minimal/RoomListScreen.kt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts index f9bfc63932..7a3540e7eb 100644 --- a/samples/minimal/build.gradle.kts +++ b/samples/minimal/build.gradle.kts @@ -63,5 +63,6 @@ dependencies { implementation(projects.features.networkmonitor.impl) implementation(projects.services.toolbox.impl) implementation(projects.libraries.featureflag.impl) + implementation(projects.services.analytics.noop) implementation(libs.coroutines.core) } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index fe9d7d1202..17e948a1a8 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -47,6 +47,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.room.RoomMembershipObserver import io.element.android.libraries.preferences.impl.store.DefaultSessionPreferencesStore +import io.element.android.services.analytics.noop.NoopAnalyticsService import io.element.android.services.toolbox.impl.strings.AndroidStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -121,6 +122,7 @@ class RoomListScreen( sessionId = matrixClient.sessionId, sessionCoroutineScope = Singleton.appScope ), + analyticsService = NoopAnalyticsService(), ) @Composable