diff --git a/changelog.d/2969.misc b/changelog.d/2969.misc new file mode 100644 index 0000000000..327a778f74 --- /dev/null +++ b/changelog.d/2969.misc @@ -0,0 +1 @@ +Track when the user starts a room call and when they enable formatting options on the message composer diff --git a/features/call/build.gradle.kts b/features/call/build.gradle.kts index cd6e235f6a..7ff1510cef 100644 --- a/features/call/build.gradle.kts +++ b/features/call/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(projects.libraries.matrix.impl) implementation(projects.libraries.network) implementation(projects.libraries.preferences.api) + implementation(projects.services.analytics.api) implementation(projects.services.toolbox.api) implementation(libs.androidx.webkit) implementation(libs.serialization.json) @@ -56,5 +57,6 @@ dependencies { testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.analytics.test) testImplementation(projects.tests.testutils) } diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt index 1ec75ed47d..49f6352212 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.features.call.CallType import io.element.android.features.call.data.WidgetMessage import io.element.android.features.call.utils.CallWidgetProvider @@ -42,6 +43,7 @@ import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.services.analytics.api.ScreenTracker import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collect @@ -61,6 +63,7 @@ class CallScreenPresenter @AssistedInject constructor( private val clock: SystemClock, private val dispatchers: CoroutineDispatchers, private val matrixClientsProvider: MatrixClientProvider, + private val screenTracker: ScreenTracker, private val appCoroutineScope: CoroutineScope, ) : Presenter { @AssistedFactory @@ -83,6 +86,15 @@ class CallScreenPresenter @AssistedInject constructor( loadUrl(callType, urlState, callWidgetDriver) } + when (callType) { + is CallType.ExternalUrl -> { + // No analytics yet for external calls + } + is CallType.RoomCall -> { + screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall) + } + } + HandleMatrixClientSyncState() callWidgetDriver.value?.let { driver -> diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index a9ddb1bf6b..30579ad99c 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.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.MobileScreen import io.element.android.features.call.CallType import io.element.android.features.call.utils.FakeCallWidgetProvider import io.element.android.features.call.utils.FakeWidgetMessageInterceptor @@ -32,9 +33,13 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.services.analytics.api.ScreenTracker +import io.element.android.services.analytics.test.FakeScreenTracker import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilTimeout +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelAndJoin @@ -53,16 +58,20 @@ class CallScreenPresenterTest { @Test fun `present - with CallType ExternalUrl just loads the URL`() = runTest { - val presenter = createCallScreenPresenter(CallType.ExternalUrl("https://call.element.io")) + val analyticsLambda = lambdaRecorder { } + val presenter = createCallScreenPresenter( + callType = CallType.ExternalUrl("https://call.element.io"), + screenTracker = FakeScreenTracker(analyticsLambda) + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { // Wait until the URL is loaded skipItems(1) - val initialState = awaitItem() assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io")) assertThat(initialState.isInWidgetMode).isFalse() + analyticsLambda.assertions().isNeverCalled() } } @@ -70,22 +79,29 @@ class CallScreenPresenterTest { fun `present - with CallType RoomCall loads URL and runs WidgetDriver`() = runTest { val widgetDriver = FakeMatrixWidgetDriver() val widgetProvider = FakeCallWidgetProvider(widgetDriver) + val analyticsLambda = lambdaRecorder { } val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, widgetProvider = widgetProvider, + screenTracker = FakeScreenTracker(analyticsLambda) ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { // Wait until the URL is loaded skipItems(1) - val initialState = awaitItem() assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java) assertThat(initialState.isInWidgetMode).isTrue() assertThat(widgetProvider.getWidgetCalled).isTrue() assertThat(widgetDriver.runCalledCount).isEqualTo(1) + // Called several times because of the recomposition + analyticsLambda.assertions().isCalledExactly(2) + .withSequence( + listOf(value(MobileScreen.ScreenName.RoomCall)), + listOf(value(MobileScreen.ScreenName.RoomCall)) + ) } } @@ -95,6 +111,7 @@ class CallScreenPresenterTest { val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, + screenTracker = FakeScreenTracker {}, ) val messageInterceptor = FakeWidgetMessageInterceptor() moleculeFlow(RecompositionMode.Immediate) { @@ -125,6 +142,7 @@ class CallScreenPresenterTest { widgetDriver = widgetDriver, navigator = navigator, dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + screenTracker = FakeScreenTracker {}, ) val messageInterceptor = FakeWidgetMessageInterceptor() moleculeFlow(RecompositionMode.Immediate) { @@ -155,6 +173,7 @@ class CallScreenPresenterTest { widgetDriver = widgetDriver, navigator = navigator, dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + screenTracker = FakeScreenTracker {}, ) val messageInterceptor = FakeWidgetMessageInterceptor() moleculeFlow(RecompositionMode.Immediate) { @@ -185,7 +204,8 @@ class CallScreenPresenterTest { widgetDriver = widgetDriver, navigator = navigator, dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), - matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }), + screenTracker = FakeScreenTracker {}, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -208,7 +228,8 @@ class CallScreenPresenterTest { widgetDriver = widgetDriver, navigator = navigator, dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), - matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }), + screenTracker = FakeScreenTracker {}, ) val hasRun = Mutex(true) val job = launch { @@ -233,6 +254,7 @@ class CallScreenPresenterTest { widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + screenTracker: ScreenTracker = FakeScreenTracker(), ): CallScreenPresenter { val userAgentProvider = object : UserAgentProvider { override fun provide(): String { @@ -241,14 +263,15 @@ class CallScreenPresenterTest { } val clock = SystemClock { 0 } return CallScreenPresenter( - callType, - navigator, - widgetProvider, - userAgentProvider, - clock, - dispatchers, - matrixClientsProvider, - this, + callType = callType, + navigator = navigator, + callWidgetProvider = widgetProvider, + userAgentProvider = userAgentProvider, + clock = clock, + dispatchers = dispatchers, + matrixClientsProvider = matrixClientsProvider, + screenTracker = screenTracker, + appCoroutineScope = this, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 8ea3075af3..6665c5fa2a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -29,6 +29,7 @@ import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.Interaction import io.element.android.anvilannotations.ContributesNode import io.element.android.features.call.CallType import io.element.android.features.call.ui.ElementCallActivity @@ -68,6 +69,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.collections.immutable.ImmutableList import kotlinx.parcelize.Parcelize @@ -80,6 +83,7 @@ class MessagesFlowNode @AssistedInject constructor( private val sendLocationEntryPoint: SendLocationEntryPoint, private val showLocationEntryPoint: ShowLocationEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint, + private val analyticsService: AnalyticsService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Messages, @@ -188,6 +192,7 @@ class MessagesFlowNode @AssistedInject constructor( sessionId = matrixClient.sessionId, roomId = roomId, ) + analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) ElementCallActivity.start(context, inputs) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 60e8088504..f008f78d66 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.setValue import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import im.vector.app.features.analytics.plan.Composer +import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor @@ -68,6 +69,7 @@ import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CancellationException @@ -388,6 +390,9 @@ class MessageComposerPresenter @Inject constructor( is MessageComposerEvents.ToggleTextFormatting -> { showAttachmentSourcePicker = false showTextFormatting = event.enabled + if (showTextFormatting) { + analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled) + } } is MessageComposerEvents.Error -> { analyticsService.trackError(event.error) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 91f507fc51..a9a5dfe356 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -26,6 +26,7 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer +import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents @@ -768,10 +769,15 @@ class MessageComposerPresenterTest { val showTextFormatting = awaitItem() assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse() assertThat(showTextFormatting.showTextFormatting).isTrue() + assertThat(analyticsService.capturedEvents).containsExactly( + Interaction(index = null, interactionType = null, name = Interaction.Name.MobileRoomComposerFormattingEnabled) + ) + analyticsService.capturedEvents.clear() showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false)) skipItems(1) val finished = awaitItem() assertThat(finished.showTextFormatting).isFalse() + assertThat(analyticsService.capturedEvents).isEmpty() } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 798a903274..9b3cb0f8bc 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -28,6 +28,7 @@ import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.Interaction import io.element.android.anvilannotations.ContributesNode import io.element.android.features.call.CallType import io.element.android.features.call.ui.ElementCallActivity @@ -53,6 +54,8 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.parcelize.Parcelize @ContributesNode(RoomScope::class) @@ -62,6 +65,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( @ApplicationContext private val context: Context, private val pollHistoryEntryPoint: PollHistoryEntryPoint, private val room: MatrixRoom, + private val analyticsService: AnalyticsService, ) : BaseFlowNode( backstack = BackStack( initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), @@ -142,6 +146,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( sessionId = room.sessionId, roomId = room.roomId, ) + analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) ElementCallActivity.start(context, inputs) } } @@ -189,8 +194,8 @@ class RoomDetailsFlowNode @AssistedInject constructor( plugins().forEach { it.onOpenRoom(roomId) } } - override fun onStartCall(roomId: RoomId) { - ElementCallActivity.start(context, CallType.RoomCall(roomId = roomId, sessionId = room.sessionId)) + override fun onStartCall(dmRoomId: RoomId) { + ElementCallActivity.start(context, CallType.RoomCall(sessionId = room.sessionId, roomId = dmRoomId)) } } val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback) diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt index 402e07dbba..9101254126 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt @@ -83,8 +83,8 @@ class UserProfileFlowNode @AssistedInject constructor( plugins().forEach { it.onOpenRoom(roomId) } } - override fun onStartCall(roomId: RoomId) { - ElementCallActivity.start(context, CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = roomId)) + override fun onStartCall(dmRoomId: RoomId) { + ElementCallActivity.start(context, CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = dmRoomId)) } } val params = UserProfileNode.UserProfileInputs(userId = inputs().userId) diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt index 7a669772f7..027f789515 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt @@ -32,7 +32,7 @@ class UserProfileNodeHelper( interface Callback : NodeInputs { fun openAvatarPreview(username: String, avatarUrl: String) fun onStartDM(roomId: RoomId) - fun onStartCall(roomId: RoomId) + fun onStartCall(dmRoomId: RoomId) } fun onShareUser( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab0f98c5a3..14e48107a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -187,7 +187,7 @@ zxing_cpp = "io.github.zxing-cpp:android:2.2.0" posthog = "com.posthog:posthog-android:3.3.0" sentry = "io.sentry:sentry-android:7.9.0" # main branch can be tested replacing the version with main-SNAPSHOT -matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.23.0" +matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.23.1" # Emojibase matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3" diff --git a/services/analytics/test/build.gradle.kts b/services/analytics/test/build.gradle.kts index 521af86a61..2e0773809d 100644 --- a/services/analytics/test/build.gradle.kts +++ b/services/analytics/test/build.gradle.kts @@ -14,7 +14,7 @@ * limitations under the License. */ plugins { - id("io.element.android-library") + id("io.element.android-compose-library") } android { @@ -24,5 +24,6 @@ android { dependencies { implementation(projects.services.analytics.api) implementation(projects.libraries.core) + implementation(projects.tests.testutils) implementation(libs.coroutines.core) } diff --git a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeScreenTracker.kt b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeScreenTracker.kt new file mode 100644 index 0000000000..127644aa3c --- /dev/null +++ b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeScreenTracker.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.services.analytics.test + +import androidx.compose.runtime.Composable +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.services.analytics.api.ScreenTracker +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeScreenTracker( + private val trackScreenLambda: (MobileScreen.ScreenName) -> Unit = { lambdaError() } +) : ScreenTracker { + @Composable + override fun TrackScreen(screen: MobileScreen.ScreenName) { + trackScreenLambda(screen) + } +}