Merge branch 'develop' into renovate/org.matrix.rustcomponents-sdk-android-25.x

This commit is contained in:
ganfra 2025-11-19 13:10:18 +01:00
commit f4f129f420
27 changed files with 253 additions and 54 deletions

View file

@ -14,7 +14,7 @@ ij_smart_tabs = false
ij_visual_guides = none ij_visual_guides = none
ij_wrap_on_typing = false ij_wrap_on_typing = false
# Ktlint rule, for more information see https://pinterest.github.io/ktlint/1.1.1/faq/#how-do-i-enable-or-disable-a-rule # Ktlint rule, for more information see https://pinterest.github.io/ktlint/latest/faq/#how-do-i-enable-or-disable-a-rule
ktlint_standard_wrapping = disabled ktlint_standard_wrapping = disabled
ktlint_standard_trailing-comma-on-call-site = disabled ktlint_standard_trailing-comma-on-call-site = disabled
ktlint_standard_trailing-comma-on-declaration-site = disabled ktlint_standard_trailing-comma-on-declaration-site = disabled
@ -31,6 +31,10 @@ ktlint_function_naming_ignore_when_annotated_with = Composable
ktlint_standard_function-expression-body = disabled ktlint_standard_function-expression-body = disabled
ktlint_standard_chain-method-continuation = disabled ktlint_standard_chain-method-continuation = disabled
ktlint_standard_class-signature = disabled ktlint_standard_class-signature = disabled
# Added when upgrading to 1.8.0
ktlint_standard_when-entry-bracing = disabled
ktlint_standard_blank-line-between-when-conditions = disabled
ktlint_standard_mixed-condition-operators = disabled
[*.java] [*.java]
ij_java_align_consecutive_assignments = false ij_java_align_consecutive_assignments = false

View file

@ -90,6 +90,8 @@ import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.ui.common.nodes.emptyNode import io.element.android.libraries.ui.common.nodes.emptyNode
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -136,6 +138,7 @@ class LoggedInFlowNode(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Placeholder, initialElement = NavTarget.Placeholder,
@ -212,6 +215,8 @@ class LoggedInFlowNode(
matrixClient.getMaxFileUploadSize() matrixClient.getMaxFileUploadSize()
} }
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed)
ftueService.state ftueService.state
.onEach { ftueState -> .onEach { ftueState ->
when (ftueState) { when (ftueState) {

View file

@ -18,6 +18,8 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction
import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@ -39,6 +41,7 @@ class SyncOrchestrator(
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
dispatchers: CoroutineDispatchers, dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
) { ) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -69,10 +72,13 @@ class SyncOrchestrator(
// Perform an initial sync if the sync service is not running, to check whether the homeserver is accessible // Perform an initial sync if the sync service is not running, to check whether the homeserver is accessible
// Otherwise, if the device is offline the sync service will never start and the SyncState will be Idle, not Offline // Otherwise, if the device is offline the sync service will never start and the SyncState will be Idle, not Offline
Timber.tag(tag).d("performing initial sync attempt") Timber.tag(tag).d("performing initial sync attempt")
syncService.startSync() analyticsService.recordTransaction("First sync", "syncService.startSync()") { transaction ->
syncService.startSync()
// Wait until the sync service is not idle, either it will be running or in error/offline state // Wait until the sync service is not idle, either it will be running or in error/offline state
syncService.syncState.first { it != SyncState.Idle } val firstState = syncService.syncState.first { it != SyncState.Idle }
transaction.setData("first_sync_state", firstState.name)
}
observeStates() observeStates()
} }

View file

@ -13,6 +13,7 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -390,5 +391,6 @@ class SyncOrchestratorTest {
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
analyticsService = FakeAnalyticsService(),
) )
} }

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -129,6 +130,7 @@ class MatrixSessionCacheTest {
appForegroundStateService = FakeAppForegroundStateService(), appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(), networkMonitor = FakeNetworkMonitor(),
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
analyticsService = FakeAnalyticsService(),
) )
} }
} }

View file

@ -60,7 +60,7 @@ autoservice = "1.1.1"
# quality # quality
detekt = "1.23.8" detekt = "1.23.8"
# See https://github.com/pinterest/ktlint/releases/ # See https://github.com/pinterest/ktlint/releases/
ktlint = "1.7.1" ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0" androidx-test-ext-junit = "1.3.0"
kover = "0.9.1" kover = "0.9.1"
@ -141,7 +141,7 @@ accompanist_permission = { module = "com.google.accompanist:accompanist-permissi
squareup_seismic = "com.squareup:seismic:1.0.3" squareup_seismic = "com.squareup:seismic:1.0.3"
# network # network
network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.3.1" network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.3.2"
network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" } network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" }
network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" } network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" }
network_okhttp = { module = "com.squareup.okhttp3:okhttp" } network_okhttp = { module = "com.squareup.okhttp3:okhttp" }

View file

@ -76,6 +76,7 @@ import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -131,6 +132,7 @@ class RustMatrixClient(
clock: SystemClock, clock: SystemClock,
timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
) : MatrixClient { ) : MatrixClient {
override val sessionId: UserId = UserId(innerClient.userId()) override val sessionId: UserId = UserId(innerClient.userId())
override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
@ -178,6 +180,7 @@ class RustMatrixClient(
roomListFactory = RoomListFactory( roomListFactory = RoomListFactory(
innerRoomListService = innerRoomListService, innerRoomListService = innerRoomListService,
sessionCoroutineScope = sessionCoroutineScope, sessionCoroutineScope = sessionCoroutineScope,
analyticsService = analyticsService,
), ),
roomSyncSubscriber = roomSyncSubscriber, roomSyncSubscriber = roomSyncSubscriber,
) )
@ -212,6 +215,7 @@ class RustMatrixClient(
roomMembershipObserver = roomMembershipObserver, roomMembershipObserver = roomMembershipObserver,
roomInfoMapper = roomInfoMapper, roomInfoMapper = roomInfoMapper,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
analyticsService = analyticsService,
) )
override val matrixMediaLoader: MatrixMediaLoader = RustMediaLoader( override val matrixMediaLoader: MatrixMediaLoader = RustMediaLoader(

View file

@ -114,6 +114,7 @@ class RustMatrixClientFactory(
clock = clock, clock = clock,
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory, timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
analyticsService = analyticsService,
).also { ).also {
Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
} }

View file

@ -23,6 +23,9 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction
import io.element.android.services.analyticsproviders.api.recordChildTransaction
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
@ -54,6 +57,7 @@ class RustRoomFactory(
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val roomMembershipObserver: RoomMembershipObserver, private val roomMembershipObserver: RoomMembershipObserver,
private val roomInfoMapper: RoomInfoMapper, private val roomInfoMapper: RoomInfoMapper,
private val analyticsService: AnalyticsService,
) { ) {
private val dispatcher = dispatchers.io.limitedParallelism(1) private val dispatcher = dispatchers.io.limitedParallelism(1)
private val mutex = Mutex() private val mutex = Mutex()
@ -106,48 +110,64 @@ class RustRoomFactory(
Timber.d("Room factory is destroyed, returning null for $roomId") Timber.d("Room factory is destroyed, returning null for $roomId")
return@withContext null return@withContext null
} }
val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withContext null
val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withLock null
if (sdkRoom.membership() == Membership.JOINED) { if (sdkRoom.membership() == Membership.JOINED) {
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) analyticsService.recordTransaction(
// Init the live timeline in the SDK from the Room name = "Get joined room",
val timeline = sdkRoom.timelineWithConfiguration( operation = "RustRoomFactory.getJoinedRoomOrPreview",
TimelineConfiguration( ) { transaction ->
focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents), val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, // Init the live timeline in the SDK from the Room
internalIdPrefix = "live", val timeline = transaction.recordChildTransaction(
dateDividerMode = DateDividerMode.DAILY, operation = "sdkRoom.timelineWithConfiguration",
trackReadReceipts = true, description = "Get timeline from the SDK",
reportUtds = true, ) {
) sdkRoom.timelineWithConfiguration(
) TimelineConfiguration(
focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents),
filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All,
internalIdPrefix = "live",
dateDividerMode = DateDividerMode.DAILY,
trackReadReceipts = true,
reportUtds = true,
)
)
}
GetRoomResult.Joined( GetRoomResult.Joined(
JoinedRustRoom( JoinedRustRoom(
baseRoom = getBaseRoom(sdkRoom), baseRoom = getBaseRoom(sdkRoom),
notificationSettingsService = notificationSettingsService, notificationSettingsService = notificationSettingsService,
roomContentForwarder = roomContentForwarder, roomContentForwarder = roomContentForwarder,
liveInnerTimeline = timeline, liveInnerTimeline = timeline,
coroutineDispatchers = dispatchers, coroutineDispatchers = dispatchers,
systemClock = systemClock, systemClock = systemClock,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
)
) )
)
} else {
val preview = try {
sdkRoom.previewRoom(via = serverNames)
} catch (e: Exception) {
Timber.e(e, "Failed to get room preview for $roomId")
return@withContext null
} }
} else {
analyticsService.recordTransaction(
name = "Get preview of room",
operation = "RustRoomFactory.getJoinedRoomOrPreview",
) {
val preview = try {
sdkRoom.previewRoom(via = serverNames)
} catch (e: Exception) {
Timber.e(e, "Failed to get room preview for $roomId")
return@recordTransaction null
}
GetRoomResult.NotJoined( GetRoomResult.NotJoined(
NotJoinedRustRoom( NotJoinedRustRoom(
sessionId = sessionId, sessionId = sessionId,
localRoom = getBaseRoom(sdkRoom), localRoom = getBaseRoom(sdkRoom),
previewInfo = RoomPreviewInfoMapper.map(preview.info()), previewInfo = RoomPreviewInfoMapper.map(preview.info()),
)
) )
) }
} }
} }
} }

View file

@ -12,6 +12,8 @@ import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -36,6 +38,7 @@ private val ROOM_LIST_RUST_FILTERS = listOf(
internal class RoomListFactory( internal class RoomListFactory(
private val innerRoomListService: RoomListService, private val innerRoomListService: RoomListService,
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
) { ) {
private val roomSummaryDetailsFactory: RoomSummaryFactory = RoomSummaryFactory() private val roomSummaryDetailsFactory: RoomSummaryFactory = RoomSummaryFactory()
@ -59,6 +62,8 @@ internal class RoomListFactory(
val loadedPages = MutableStateFlow(1) val loadedPages = MutableStateFlow(1)
var innerRoomList: InnerRoomList? = null var innerRoomList: InnerRoomList? = null
val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow")
coroutineScope.launch(coroutineContext) { coroutineScope.launch(coroutineContext) {
innerRoomList = innerProvider() innerRoomList = innerProvider()
innerRoomList.let { innerRoomList -> innerRoomList.let { innerRoomList ->
@ -67,6 +72,10 @@ internal class RoomListFactory(
roomListDynamicEvents = dynamicEvents, roomListDynamicEvents = dynamicEvents,
initialFilterKind = RoomListEntriesDynamicFilterKind.All(ROOM_LIST_RUST_FILTERS), initialFilterKind = RoomListEntriesDynamicFilterKind.All(ROOM_LIST_RUST_FILTERS),
).onEach { update -> ).onEach { update ->
if (!firstRoomsTransaction.isFinished()) {
analyticsService.stopLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed)
firstRoomsTransaction.finish()
}
processor.postUpdate(update) processor.postUpdate(update)
}.launchIn(this) }.launchIn(this)

View file

@ -30,7 +30,7 @@ import org.matrix.rustcomponents.sdk.SyncService as InnerSyncService
class RustSyncService( class RustSyncService(
private val inner: InnerSyncService, private val inner: InnerSyncService,
private val dispatcher: CoroutineDispatcher, private val dispatcher: CoroutineDispatcher,
sessionCoroutineScope: CoroutineScope sessionCoroutineScope: CoroutineScope,
) : SyncService { ) : SyncService {
private val isServiceReady = AtomicBoolean(true) private val isServiceReady = AtomicBoolean(true)
@ -71,10 +71,10 @@ class RustSyncService(
override val syncState: StateFlow<SyncState> = override val syncState: StateFlow<SyncState> =
inner.stateFlow() inner.stateFlow()
.map(SyncServiceState::toSyncState) .map(SyncServiceState::toSyncState)
.distinctUntilChanged()
.onEach { state -> .onEach { state ->
Timber.i("Sync state=$state") Timber.i("Sync state=$state")
} }
.distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle)
override val isOnline: StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline } override val isOnline: StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline }

View file

@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@ -116,5 +117,6 @@ class RustMatrixClientTest {
clock = FakeSystemClock(), clock = FakeSystemClock(),
timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(), timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(),
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
analyticsService = FakeAnalyticsService(),
) )
} }

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomList import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomList
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Ignore import org.junit.Ignore
import org.junit.Test import org.junit.Test
@ -22,6 +23,7 @@ class RoomListFactoryTest {
val sut = RoomListFactory( val sut = RoomListFactory(
innerRoomListService = FakeFfiRoomListService(), innerRoomListService = FakeFfiRoomListService(),
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
analyticsService = FakeAnalyticsService(),
) )
sut.createRoomList( sut.createRoomList(
pageSize = 10, pageSize = 10,

View file

@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@ -52,6 +53,7 @@ private fun TestScope.createRustRoomListService(
roomListFactory = RoomListFactory( roomListFactory = RoomListFactory(
innerRoomListService = roomListService, innerRoomListService = roomListService,
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
analyticsService = FakeAnalyticsService(),
), ),
roomSyncSubscriber = RoomSyncSubscriber( roomSyncSubscriber = RoomSyncSubscriber(
roomListService = roomListService, roomListService = roomListService,

View file

@ -72,10 +72,6 @@ class DefaultNotificationChannels(
createNotificationChannels() createNotificationChannels()
} }
/* ==========================================================================================
* Channel names
* ========================================================================================== */
/** /**
* Create notification channels. * Create notification channels.
*/ */
@ -110,10 +106,7 @@ class DefaultNotificationChannels(
} }
} }
/** // Default notification importance: shows everywhere, makes noise, but does not visually intrude.
* Default notification importance: shows everywhere, makes noise, but does not visually
* intrude.
*/
notificationManager.createNotificationChannel( notificationManager.createNotificationChannel(
NotificationChannelCompat.Builder( NotificationChannelCompat.Builder(
NOISY_NOTIFICATION_CHANNEL_ID, NOISY_NOTIFICATION_CHANNEL_ID,
@ -138,9 +131,7 @@ class DefaultNotificationChannels(
.build() .build()
) )
/** // Low notification importance: shows everywhere, but is not intrusive.
* Low notification importance: shows everywhere, but is not intrusive.
*/
notificationManager.createNotificationChannel( notificationManager.createNotificationChannel(
NotificationChannelCompat.Builder( NotificationChannelCompat.Builder(
SILENT_NOTIFICATION_CHANNEL_ID, SILENT_NOTIFICATION_CHANNEL_ID,

View file

@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.services.analytics.api
sealed class AnalyticsLongRunningTransaction(
val name: String,
val operation: String?,
) {
data object FirstRoomsDisplayed : AnalyticsLongRunningTransaction("First rooms displayed after login or restoration", null)
}

View file

@ -9,6 +9,7 @@
package io.element.android.services.analytics.api package io.element.android.services.analytics.api
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import io.element.android.services.analyticsproviders.api.trackers.AnalyticsTracker import io.element.android.services.analyticsproviders.api.trackers.AnalyticsTracker
import io.element.android.services.analyticsproviders.api.trackers.ErrorTracker import io.element.android.services.analyticsproviders.api.trackers.ErrorTracker
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -48,4 +49,23 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker {
* Update analyticsId from the AccountData. * Update analyticsId from the AccountData.
*/ */
suspend fun setAnalyticsId(analyticsId: String) suspend fun setAnalyticsId(analyticsId: String)
/**
* Starts a transaction to measure the performance of an operation.
*/
fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction
fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction)
fun stopLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction)
}
inline fun <T> AnalyticsService.recordTransaction(name: String, operation: String, block: (AnalyticsTransaction) -> T): T {
val transaction = startTransaction(name, operation)
try {
val result = block(transaction)
return result
} finally {
transaction.finish()
}
} }

View file

@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.services.analytics.api
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
object NoopAnalyticsTransaction : AnalyticsTransaction {
override fun startChild(operation: String, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction
override fun setData(key: String, value: Any) {}
override fun isFinished(): Boolean = true
override fun finish() {}
}

View file

@ -19,15 +19,19 @@ import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.NoopAnalyticsTransaction
import io.element.android.services.analytics.impl.log.analyticsTag import io.element.android.services.analytics.impl.log.analyticsTag
import io.element.android.services.analytics.impl.store.AnalyticsStore import io.element.android.services.analytics.impl.store.AnalyticsStore
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ -40,6 +44,8 @@ class DefaultAnalyticsService(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver, private val sessionObserver: SessionObserver,
) : AnalyticsService, SessionListener { ) : AnalyticsService, SessionListener {
private val pendingLongRunningTransactions = ConcurrentHashMap<AnalyticsLongRunningTransaction, AnalyticsTransaction>()
// Cache for the store values // Cache for the store values
private val userConsent = AtomicBoolean(false) private val userConsent = AtomicBoolean(false)
@ -138,4 +144,20 @@ class DefaultAnalyticsService(
analyticsProviders.onEach { it.trackError(throwable) } analyticsProviders.onEach { it.trackError(throwable) }
} }
} }
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction {
return if (userConsent.get()) {
analyticsProviders.firstNotNullOfOrNull { it.startTransaction(name, operation) }
} else {
null
} ?: NoopAnalyticsTransaction
}
override fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) {
pendingLongRunningTransactions[longRunningTransaction] = startTransaction(longRunningTransaction.name, longRunningTransaction.operation)
}
override fun stopLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) {
pendingLongRunningTransactions.remove(longRunningTransaction)?.finish()
}
} }

View file

@ -15,8 +15,11 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.NoopAnalyticsTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -35,4 +38,7 @@ class NoopAnalyticsService : AnalyticsService {
override fun updateUserProperties(userProperties: UserProperties) = Unit override fun updateUserProperties(userProperties: UserProperties) = Unit
override fun trackError(throwable: Throwable) = Unit override fun trackError(throwable: Throwable) = Unit
override fun updateSuperProperties(updatedProperties: SuperProperties) = Unit override fun updateSuperProperties(updatedProperties: SuperProperties) = Unit
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction
override fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) {}
override fun stopLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) {}
} }

View file

@ -12,8 +12,11 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.NoopAnalyticsTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -65,4 +68,8 @@ class FakeAnalyticsService(
override fun updateSuperProperties(updatedProperties: SuperProperties) { override fun updateSuperProperties(updatedProperties: SuperProperties) {
// No op // No op
} }
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction
override fun startLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) {}
override fun stopLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) {}
} }

View file

@ -20,4 +20,6 @@ interface AnalyticsProvider : AnalyticsTracker, ErrorTracker {
fun init() fun init()
fun stop() fun stop()
fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction?
} }

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.services.analyticsproviders.api
interface AnalyticsTransaction {
fun startChild(operation: String, description: String? = null): AnalyticsTransaction
fun setData(key: String, value: Any)
fun isFinished(): Boolean
fun finish()
}
inline fun <T> AnalyticsTransaction.recordChildTransaction(operation: String, description: String? = null, block: (AnalyticsTransaction) -> T): T {
val child = startChild(operation, description)
try {
val result = block(child)
return result
} finally {
child.finish()
}
}

View file

@ -17,6 +17,7 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import io.element.android.services.analyticsproviders.posthog.log.analyticsTag import io.element.android.services.analyticsproviders.posthog.log.analyticsTag
import timber.log.Timber import timber.log.Timber
@ -122,6 +123,8 @@ class PosthogAnalyticsProvider(
} }
return withSuperProperties.takeIf { it.isEmpty().not() } return withSuperProperties.takeIf { it.isEmpty().not() }
} }
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? = null
} }
private fun Map<String, Any?>.keepOnlyNonNullValues(): Map<String, Any> { private fun Map<String, Any?>.keepOnlyNonNullValues(): Map<String, Any> {

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import io.element.android.services.analyticsproviders.sentry.log.analyticsTag import io.element.android.services.analyticsproviders.sentry.log.analyticsTag
import io.sentry.Breadcrumb import io.sentry.Breadcrumb
import io.sentry.Sentry import io.sentry.Sentry
@ -51,6 +52,7 @@ class SentryAnalyticsProvider(
options.isEnableUserInteractionTracing = true options.isEnableUserInteractionTracing = true
options.environment = buildMeta.buildType.toSentryEnv() options.environment = buildMeta.buildType.toSentryEnv()
} }
Timber.tag(analyticsTag.value).d("Sentry was initialized correctly")
} }
override fun stop() { override fun stop() {
@ -87,6 +89,10 @@ class SentryAnalyticsProvider(
override fun trackError(throwable: Throwable) { override fun trackError(throwable: Throwable) {
Sentry.captureException(throwable) Sentry.captureException(throwable)
} }
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? {
return SentryAnalyticsTransaction(name, operation)
}
} }
private fun BuildType.toSentryEnv() = when (this) { private fun BuildType.toSentryEnv() = when (this) {

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.services.analyticsproviders.sentry
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import io.sentry.ISpan
import io.sentry.Sentry
class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTransaction {
constructor(name: String, operation: String?) : this(Sentry.startTransaction(name, operation.orEmpty()))
private val inner = span
override fun startChild(operation: String, description: String?): AnalyticsTransaction = SentryAnalyticsTransaction(
inner.startChild(operation, description)
)
override fun setData(key: String, value: Any) = inner.setData(key, value)
override fun isFinished(): Boolean = inner.isFinished
override fun finish() = inner.finish()
}

View file

@ -13,6 +13,7 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
class FakeAnalyticsProvider( class FakeAnalyticsProvider(
@ -32,4 +33,5 @@ class FakeAnalyticsProvider(
override fun updateUserProperties(userProperties: UserProperties) = updateUserPropertiesLambda(userProperties) override fun updateUserProperties(userProperties: UserProperties) = updateUserPropertiesLambda(userProperties)
override fun trackError(throwable: Throwable) = trackErrorLambda(throwable) override fun trackError(throwable: Throwable) = trackErrorLambda(throwable)
override fun updateSuperProperties(updatedProperties: SuperProperties) = updateSuperPropertiesLambda(updatedProperties) override fun updateSuperProperties(updatedProperties: SuperProperties) = updateSuperPropertiesLambda(updatedProperties)
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? = null
} }