First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.services.analytics.api"
}
dependencies {
api(projects.services.analyticsproviders.api)
api(projects.services.toolbox.api)
implementation(libs.coroutines.core)
implementation(projects.libraries.core)
}

View File

@@ -0,0 +1,22 @@
/*
* 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 ColdStartUntilCachedRoomList : AnalyticsLongRunningTransaction("Cold start until cached room list is displayed", null)
data object FirstRoomsDisplayed : AnalyticsLongRunningTransaction("First rooms displayed after login or restoration", null)
data object ResumeAppUntilNewRoomsReceived : AnalyticsLongRunningTransaction("App was resumed and new room list items arrived", null)
data object NotificationTapOpensTimeline : AnalyticsLongRunningTransaction("A notification was tapped and it opened a timeline", null)
data object OpenRoom : AnalyticsLongRunningTransaction("Open a room and see loaded items in the timeline", null)
data object LoadJoinedRoomFlow : AnalyticsLongRunningTransaction("Load joined room UI", "ui.load")
data object LoadMessagesUi : AnalyticsLongRunningTransaction("Load messages UI", "ui.load")
data object DisplayFirstTimelineItems : AnalyticsLongRunningTransaction("Get and display first timeline items", null)
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.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.ErrorTracker
import kotlinx.coroutines.flow.Flow
interface AnalyticsService : AnalyticsTracker, ErrorTracker {
/**
* Get the available analytics providers.
*/
fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider>
/**
* A Flow of Boolean, true if the user has given their consent.
*/
val userConsentFlow: Flow<Boolean>
/**
* Update the user consent value.
*/
suspend fun setUserConsent(userConsent: Boolean)
/**
* A Flow of Boolean, true if the user has been asked for their consent.
*/
val didAskUserConsentFlow: Flow<Boolean>
/**
* Store the fact that the user has been asked for their consent.
*/
suspend fun setDidAskUserConsent()
/**
* A Flow of String, used for analytics Id.
*/
val analyticsIdFlow: Flow<String>
/**
* Update analyticsId from the AccountData.
*/
suspend fun setAnalyticsId(analyticsId: String)
/**
* Starts a transaction to measure the performance of an operation.
*/
fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction
/**
* Starts an [AnalyticsLongRunningTransaction], that can be shared with other components.
*/
fun startLongRunningTransaction(
longRunningTransaction: AnalyticsLongRunningTransaction,
parentTransaction: AnalyticsTransaction? = null
): AnalyticsTransaction
/**
* Gets an ongoing [AnalyticsLongRunningTransaction], if it exists.
*/
fun getLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction?
/**
* Removes an ongoing [AnalyticsLongRunningTransaction] so it's no longer shared.
*/
fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction?
}
inline fun <T> AnalyticsService.recordTransaction(
name: String,
operation: String,
parentTransaction: AnalyticsTransaction? = null,
block: (AnalyticsTransaction) -> T
): T {
val transaction = parentTransaction?.startChild(name, operation)
?: startTransaction(name, operation)
try {
val result = block(transaction)
return result
} finally {
transaction.finish()
}
}
/**
* Cancels a long running transaction. It behaves the same as [AnalyticsService.removeLongRunningTransaction],
* but it doesn't return the transaction so we can't finish it later.
*/
fun AnalyticsService.cancelLongRunningTransaction(
longRunningTransaction: AnalyticsLongRunningTransaction
) = removeLongRunningTransaction(longRunningTransaction)
/**
* Finishes a long running transaction if it exists. Optionally performs an [action] with the transaction before finishing it.
*/
fun AnalyticsService.finishLongRunningTransaction(
longRunningTransaction: AnalyticsLongRunningTransaction,
action: (AnalyticsTransaction) -> Unit = {},
) {
removeLongRunningTransaction(longRunningTransaction)?.let {
action(it)
it.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

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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 androidx.compose.runtime.Composable
import im.vector.app.features.analytics.plan.MobileScreen
interface ScreenTracker {
@Composable
fun TrackScreen(
screen: MobileScreen.ScreenName,
)
}

View File

@@ -0,0 +1,18 @@
/*
* 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.watchers
/**
* Adds a performance check transaction measuring the time between a cold start (or, after we read the user consent after a cold start)
* until the cached room list is displayed. This check only takes place in a cold app start after the user is authenticated.
*/
interface AnalyticsColdStartWatcher {
fun start()
fun whenLoggingIn()
fun onRoomListVisible()
}

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.watchers
/**
* This component is used to check how long it takes for the room list to be up to date after opening the app while it's on a 'warm' state:
* the app was previously running and we just returned to it.
*/
interface AnalyticsRoomListStateWatcher {
fun start()
fun stop()
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.services.analytics.compose"
}
dependencies {
api(projects.services.analytics.api)
implementation(projects.services.analytics.noop)
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.compose
import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.noop.NoopAnalyticsService
/**
* Global key to access the [AnalyticsService] in the composition tree.
*/
val LocalAnalyticsService = staticCompositionLocalOf<AnalyticsService> {
NoopAnalyticsService()
}

View File

@@ -0,0 +1,44 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.services.analytics.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.services.appnavstate.api)
api(projects.services.analyticsproviders.api)
api(projects.services.analytics.api)
implementation(libs.androidx.datastore.preferences)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.analyticsproviders.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.test)
}

View File

@@ -0,0 +1,174 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties
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.SessionObserver
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
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.store.AnalyticsStore
import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class, binding = binding<AnalyticsService>())
class DefaultAnalyticsService(
private val analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider>,
private val analyticsStore: AnalyticsStore,
// private val lateInitUserPropertiesFactory: LateInitUserPropertiesFactory,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver,
) : AnalyticsService, SessionListener {
private val pendingLongRunningTransactions = ConcurrentHashMap<AnalyticsLongRunningTransaction, AnalyticsTransaction>()
// Cache for the store values
private val userConsent = AtomicBoolean(false)
// Cache for the properties to send
private var pendingUserProperties: UserProperties? = null
override val userConsentFlow: Flow<Boolean> = analyticsStore.userConsentFlow
override val didAskUserConsentFlow: Flow<Boolean> = analyticsStore.didAskUserConsentFlow
override val analyticsIdFlow: Flow<String> = analyticsStore.analyticsIdFlow
init {
observeUserConsent()
observeSessions()
}
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> {
return analyticsProviders
}
override suspend fun setUserConsent(userConsent: Boolean) {
Timber.tag(analyticsTag.value).d("setUserConsent($userConsent)")
analyticsStore.setUserConsent(userConsent)
}
override suspend fun setDidAskUserConsent() {
Timber.tag(analyticsTag.value).d("setDidAskUserConsent()")
analyticsStore.setDidAskUserConsent()
}
override suspend fun setAnalyticsId(analyticsId: String) {
Timber.tag(analyticsTag.value).d("setAnalyticsId($analyticsId)")
analyticsStore.setAnalyticsId(analyticsId)
}
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
// Delete the store when the last session is deleted
if (wasLastSession) {
analyticsStore.reset()
}
}
private fun observeUserConsent() {
userConsentFlow
.onEach { consent ->
Timber.tag(analyticsTag.value).d("User consent updated to $consent")
userConsent.set(consent)
initOrStop()
}
.launchIn(coroutineScope)
}
private fun observeSessions() {
sessionObserver.addListener(this)
}
private fun initOrStop() {
if (userConsent.get()) {
analyticsProviders.onEach { it.init() }
pendingUserProperties?.let {
analyticsProviders.onEach { provider -> provider.updateUserProperties(it) }
pendingUserProperties = null
}
} else {
analyticsProviders.onEach { it.stop() }
}
}
override fun capture(event: VectorAnalyticsEvent) {
Timber.tag(analyticsTag.value).d("capture($event)")
if (userConsent.get()) {
analyticsProviders.onEach { it.capture(event) }
}
}
override fun screen(screen: VectorAnalyticsScreen) {
Timber.tag(analyticsTag.value).d("screen($screen)")
if (userConsent.get()) {
analyticsProviders.onEach { it.screen(screen) }
}
}
override fun updateUserProperties(userProperties: UserProperties) {
if (userConsent.get()) {
analyticsProviders.onEach { it.updateUserProperties(userProperties) }
} else {
pendingUserProperties = userProperties
}
}
override fun updateSuperProperties(updatedProperties: SuperProperties) {
this.analyticsProviders.onEach { it.updateSuperProperties(updatedProperties) }
}
override fun trackError(throwable: Throwable) {
if (userConsent.get()) {
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,
parentTransaction: AnalyticsTransaction?,
): AnalyticsTransaction {
val transaction = parentTransaction?.startChild(longRunningTransaction.name, longRunningTransaction.operation)
?: startTransaction(longRunningTransaction.name, longRunningTransaction.operation)
pendingLongRunningTransactions[longRunningTransaction] = transaction
return transaction
}
override fun getLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? {
return pendingLongRunningTransactions[longRunningTransaction]
}
override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? {
return pendingLongRunningTransactions.remove(longRunningTransaction)
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.toolbox.api.systemclock.SystemClock
@ContributesBinding(AppScope::class)
class DefaultScreenTracker(
private val analyticsService: AnalyticsService,
private val systemClock: SystemClock,
) : ScreenTracker {
@Composable
override fun TrackScreen(
screen: MobileScreen.ScreenName,
) {
var startTime by remember { mutableLongStateOf(0L) }
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
startTime = systemClock.epochMillis()
}
Lifecycle.Event.ON_PAUSE -> analyticsService.screen(
screen = MobileScreen(
durationMs = (systemClock.epochMillis() - startTime).toInt(),
screenName = screen
)
)
else -> Unit
}
}
}
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2021-2025 New Vector 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.impl.log
import io.element.android.libraries.core.log.logger.LoggerTag
val analyticsTag = LoggerTag("Analytics")

View File

@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2021-2025 New Vector 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.impl.store
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Local storage for:
* - user consent (Boolean);
* - did ask user consent (Boolean);
* - analytics Id (String).
*/
interface AnalyticsStore {
val userConsentFlow: Flow<Boolean>
val didAskUserConsentFlow: Flow<Boolean>
val analyticsIdFlow: Flow<String>
suspend fun setUserConsent(newUserConsent: Boolean)
suspend fun setDidAskUserConsent(newValue: Boolean = true)
suspend fun setAnalyticsId(newAnalyticsId: String)
suspend fun reset()
}
@ContributesBinding(AppScope::class)
class DefaultAnalyticsStore(
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : AnalyticsStore {
private val userConsent = booleanPreferencesKey("user_consent")
private val didAskUserConsent = booleanPreferencesKey("did_ask_user_consent")
private val analyticsId = stringPreferencesKey("analytics_id")
private val dataStore = preferenceDataStoreFactory.create("vector_analytics")
override val userConsentFlow: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[userConsent].orFalse() }
.distinctUntilChanged()
override val didAskUserConsentFlow: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[didAskUserConsent].orFalse() }
.distinctUntilChanged()
override val analyticsIdFlow: Flow<String> = dataStore.data
.map { preferences -> preferences[analyticsId].orEmpty() }
.distinctUntilChanged()
override suspend fun setUserConsent(newUserConsent: Boolean) {
dataStore.edit { settings ->
settings[userConsent] = newUserConsent
}
}
override suspend fun setDidAskUserConsent(newValue: Boolean) {
dataStore.edit { settings ->
settings[didAskUserConsent] = newValue
}
}
override suspend fun setAnalyticsId(newAnalyticsId: String) {
dataStore.edit { settings ->
settings[analyticsId] = newAnalyticsId
}
}
override suspend fun reset() {
dataStore.edit {
it.clear()
}
}
}

View File

@@ -0,0 +1,63 @@
/*
* 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.impl.watchers
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.cancelLongRunningTransaction
import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultAnalyticsColdStartWatcher(
private val analyticsService: AnalyticsService,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : AnalyticsColdStartWatcher {
private val isColdStart = AtomicBoolean(true)
override fun start() {
analyticsService.userConsentFlow
.onEach { hasConsent ->
if (hasConsent) {
if (isColdStart.get()) {
Timber.d("Starting cold start check")
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList)
} else {
error("The app is no longer in a cold start state")
}
}
}
.catch { Timber.w(it.message) }
.launchIn(appCoroutineScope)
}
override fun whenLoggingIn() {
if (isColdStart.getAndSet(false)) {
analyticsService.cancelLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList)
Timber.d("Canceled cold start check: user is logging in")
}
}
override fun onRoomListVisible() {
if (isColdStart.getAndSet(false)) {
Timber.d("Room list is visible, finishing cold start check")
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList)
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* 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.impl.watchers
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.analytics.api.watchers.AnalyticsRoomListStateWatcher
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
@ContributesBinding(SessionScope::class)
class DefaultAnalyticsRoomListStateWatcher(
private val appNavigationStateService: AppNavigationStateService,
private val roomListService: RoomListService,
private val analyticsService: AnalyticsService,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
dispatchers: CoroutineDispatchers,
) : AnalyticsRoomListStateWatcher {
private val coroutineScope: CoroutineScope = sessionCoroutineScope.childScope(dispatchers.computation, "AnalyticsRoomListStateWatcher")
private val isStarted = AtomicBoolean(false)
private val isWarmState = AtomicBoolean(false)
override fun start() {
if (isStarted.getAndSet(true)) {
Timber.w("Can't start RoomListStateWatcher, it's already running.")
return
}
appNavigationStateService.appNavigationState
.map { it.isInForeground }
.distinctUntilChanged()
.withPreviousValue()
.onEach { (wasInForeground, isInForeground) ->
if (isInForeground && roomListService.state.value != RoomListService.State.Running) {
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived)
}
if (wasInForeground == false && isInForeground) {
isWarmState.set(true)
}
}
.launchIn(coroutineScope)
roomListService.state
.onEach { state ->
if (state == RoomListService.State.Running && isWarmState.get()) {
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived)
}
}
.launchIn(coroutineScope)
}
override fun stop() {
if (isStarted.getAndSet(false)) {
coroutineScope.coroutineContext.cancelChildren()
}
}
}

View File

@@ -0,0 +1,300 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.services.analytics.impl
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.analytics.plan.PollEnd
import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
import io.element.android.services.analytics.impl.store.AnalyticsStore
import io.element.android.services.analytics.impl.store.FakeAnalyticsStore
import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.test.FakeAnalyticsProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultAnalyticsServiceTest {
@Test
fun `getAvailableAnalyticsProviders return the set of provider`() = runTest {
val providers = setOf(
FakeAnalyticsProvider(name = "provider1", stopLambda = { }),
FakeAnalyticsProvider(name = "provider2", stopLambda = { }),
)
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsProviders = providers
)
val result = sut.getAvailableAnalyticsProviders()
assertThat(result).isEqualTo(providers)
}
@Test
fun `when consent is not provided, capture is no op`() = runTest {
val sut = createDefaultAnalyticsService(backgroundScope)
sut.capture(anEvent)
}
@Test
fun `when consent is provided, capture is sent to the AnalyticsProvider`() = runTest {
val initLambda = lambdaRecorder<Unit> { }
val captureLambda = lambdaRecorder<VectorAnalyticsEvent, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsStore = FakeAnalyticsStore(defaultUserConsent = true),
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = initLambda,
captureLambda = captureLambda,
)
)
)
initLambda.assertions().isCalledOnce()
sut.capture(anEvent)
captureLambda.assertions().isCalledOnce().with(value(anEvent))
}
@Test
fun `when consent is not provided, screen is no op`() = runTest {
val sut = createDefaultAnalyticsService(backgroundScope)
sut.screen(aScreen)
}
@Test
fun `when consent is provided, screen is sent to the AnalyticsProvider`() = runTest {
val initLambda = lambdaRecorder<Unit> { }
val screenLambda = lambdaRecorder<VectorAnalyticsScreen, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsStore = FakeAnalyticsStore(defaultUserConsent = true),
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = initLambda,
screenLambda = screenLambda,
)
)
)
initLambda.assertions().isCalledOnce()
sut.screen(aScreen)
screenLambda.assertions().isCalledOnce().with(value(aScreen))
}
@Test
fun `when consent is not provided, trackError is no op`() = runTest {
val sut = createDefaultAnalyticsService(backgroundScope)
sut.trackError(anError)
}
@Test
fun `when consent is provided, trackError is sent to the AnalyticsProvider`() = runTest {
val initLambda = lambdaRecorder<Unit> { }
val trackErrorLambda = lambdaRecorder<Throwable, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsStore = FakeAnalyticsStore(defaultUserConsent = true),
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = initLambda,
trackErrorLambda = trackErrorLambda,
)
)
)
initLambda.assertions().isCalledOnce()
sut.trackError(anError)
trackErrorLambda.assertions().isCalledOnce().with(value(anError))
}
@Test
fun `setUserConsent is sent to the store`() = runTest {
val store = FakeAnalyticsStore()
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsStore = store,
)
assertThat(store.userConsentFlow.first()).isFalse()
assertThat(sut.userConsentFlow.first()).isFalse()
sut.setUserConsent(true)
assertThat(store.userConsentFlow.first()).isTrue()
assertThat(sut.userConsentFlow.first()).isTrue()
}
@Test
fun `setAnalyticsId is sent to the store`() = runTest {
val store = FakeAnalyticsStore()
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsStore = store,
)
assertThat(store.analyticsIdFlow.first()).isEqualTo("")
assertThat(sut.analyticsIdFlow.first()).isEqualTo("")
sut.setAnalyticsId(AN_ID)
assertThat(store.analyticsIdFlow.first()).isEqualTo(AN_ID)
assertThat(sut.analyticsIdFlow.first()).isEqualTo(AN_ID)
}
@Test
fun `setDidAskUserConsent is sent to the store`() = runTest {
val store = FakeAnalyticsStore()
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsStore = store,
)
assertThat(store.didAskUserConsentFlow.first()).isFalse()
assertThat(sut.didAskUserConsentFlow.first()).isFalse()
sut.setDidAskUserConsent()
assertThat(store.didAskUserConsentFlow.first()).isTrue()
assertThat(sut.didAskUserConsentFlow.first()).isTrue()
}
@Test
fun `when the last session is deleted, the store is reset`() = runTest {
val resetLambda = lambdaRecorder<Unit> { }
val store = FakeAnalyticsStore(
resetLambda = resetLambda,
)
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsStore = store,
)
sut.onSessionDeleted("userId", true)
resetLambda.assertions().isCalledOnce()
}
@Test
fun `when a session is deleted, the store is not reset if it was not the last session`() = runTest {
val resetLambda = lambdaRecorder<Unit> { }
val store = FakeAnalyticsStore(
resetLambda = resetLambda,
)
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsStore = store,
)
sut.onSessionDeleted("userId", false)
resetLambda.assertions().isNeverCalled()
}
@Test
fun `when a session is added, nothing happen`() = runTest {
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
)
sut.onSessionCreated("userId")
}
@Test
fun `when consent is not provided, updateUserProperties is stored for future use`() = runTest {
val completable = CompletableDeferred<Unit>()
val updateUserPropertiesLambda = lambdaRecorder<UserProperties, Unit> { _ ->
completable.complete(Unit)
}
launch {
val sut = createDefaultAnalyticsService(
coroutineScope = this,
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = { },
stopLambda = { },
updateUserPropertiesLambda = updateUserPropertiesLambda,
)
)
)
sut.updateUserProperties(aUserProperty)
updateUserPropertiesLambda.assertions().isNeverCalled()
// Give user consent
sut.setUserConsent(true)
completable.await()
updateUserPropertiesLambda.assertions().isCalledOnce().with(value(aUserProperty))
cancel()
}
}
@Test
fun `when consent is provided, updateUserProperties is sent to the provider`() = runTest {
val updateUserPropertiesLambda = lambdaRecorder<UserProperties, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = { },
updateUserPropertiesLambda = updateUserPropertiesLambda,
)
),
analyticsStore = FakeAnalyticsStore(defaultUserConsent = true),
)
sut.updateUserProperties(aUserProperty)
updateUserPropertiesLambda.assertions().isCalledOnce().with(value(aUserProperty))
}
@Test
fun `when super properties are updated, updateSuperProperties is sent to the provider`() = runTest {
val updateSuperPropertiesLambda = lambdaRecorder<SuperProperties, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = backgroundScope,
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = { },
updateSuperPropertiesLambda = updateSuperPropertiesLambda,
)
),
analyticsStore = FakeAnalyticsStore(defaultUserConsent = true),
)
sut.updateSuperProperties(aSuperProperty)
updateSuperPropertiesLambda.assertions().isCalledOnce().with(value(aSuperProperty))
}
private suspend fun createDefaultAnalyticsService(
coroutineScope: CoroutineScope,
analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider> = setOf(
FakeAnalyticsProvider(
stopLambda = { },
)
),
analyticsStore: AnalyticsStore = FakeAnalyticsStore(),
sessionObserver: SessionObserver = NoOpSessionObserver(),
) = DefaultAnalyticsService(
analyticsProviders = analyticsProviders,
analyticsStore = analyticsStore,
coroutineScope = coroutineScope,
sessionObserver = sessionObserver,
).also {
// Wait for the service to be ready
delay(1)
}
private companion object {
private val anEvent = PollEnd()
private val aScreen = MobileScreen(screenName = MobileScreen.ScreenName.User)
private val aUserProperty = UserProperties(
ftueUseCaseSelection = UserProperties.FtueUseCaseSelection.WorkMessaging,
)
private val aSuperProperty = SuperProperties(
appPlatform = SuperProperties.AppPlatform.EXA,
cryptoSDK = SuperProperties.CryptoSDK.Rust,
cryptoSDKVersion = "0.0"
)
private val anError = Exception("a reason")
private const val AN_ID = "anId"
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.impl
import androidx.lifecycle.Lifecycle
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.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.FakeLifecycleOwner
import io.element.android.tests.testutils.withFakeLifecycleOwner
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultScreenTrackerTest {
@Test
fun `TrackScreen is working as expected`() = runTest {
val analyticsService = FakeAnalyticsService()
val systemClock = FakeSystemClock(150)
val lifecycleOwner = FakeLifecycleOwner()
val sut = createDefaultScreenTracker(
analyticsService = analyticsService,
systemClock = systemClock,
)
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(lifecycleOwner) {
sut.TrackScreen(MobileScreen.ScreenName.RoomMembers)
}
}.test {
// Screen resumes
lifecycleOwner.givenState(Lifecycle.State.RESUMED)
assertThat(awaitItem()).isEqualTo(Unit)
systemClock.epochMillisResult = 450
lifecycleOwner.givenState(Lifecycle.State.DESTROYED)
}
assertThat(analyticsService.screenEvents).containsExactly(
MobileScreen(
screenName = MobileScreen.ScreenName.RoomMembers,
durationMs = 300,
)
)
}
}
private fun createDefaultScreenTracker(
analyticsService: AnalyticsService = FakeAnalyticsService(),
systemClock: SystemClock = FakeSystemClock(),
) = DefaultScreenTracker(
analyticsService = analyticsService,
systemClock = systemClock,
)

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.impl.store
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow
internal class FakeAnalyticsStore(
defaultUserConsent: Boolean = false,
defaultDidAskUserConsent: Boolean = false,
defaultAnalyticsId: String = "",
private val resetLambda: () -> Unit = { lambdaError() },
) : AnalyticsStore {
override val userConsentFlow = MutableStateFlow(defaultUserConsent)
override val didAskUserConsentFlow = MutableStateFlow(defaultDidAskUserConsent)
override val analyticsIdFlow = MutableStateFlow(defaultAnalyticsId)
override suspend fun setUserConsent(newUserConsent: Boolean) {
userConsentFlow.emit(newUserConsent)
}
override suspend fun setDidAskUserConsent(newValue: Boolean) {
didAskUserConsentFlow.emit(newValue)
}
override suspend fun setAnalyticsId(newAnalyticsId: String) {
analyticsIdFlow.emit(newAnalyticsId)
}
override suspend fun reset() {
resetLambda()
}
}

View File

@@ -0,0 +1,107 @@
/*
* 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.impl.watchers
import com.google.common.truth.Truth.assertThat
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultAnalyticsColdStartWatcherTest {
@Test
fun `watch - until room list is visible`() = runTest {
val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsColdStartWatcher(analyticsService)
// Start watching
watcher.start()
// The user has given analytics consent, we can start tracking the cold start
analyticsService.setUserConsent(true)
runCurrent()
// The transaction is running
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNotNull()
// As soon as the room list is visible
watcher.onRoomListVisible()
runCurrent()
// The transaction is now finished
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull()
}
@Test
fun `watch - user is logging in, transaction is cancelled since it's not a cold start`() = runTest {
val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsColdStartWatcher(analyticsService)
// Start watching
watcher.start()
// The user has given analytics consent, we can start tracking the cold start
analyticsService.setUserConsent(true)
runCurrent()
// The transaction is running
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNotNull()
// If the user starts a login flow
watcher.whenLoggingIn()
runCurrent()
// The transaction is gone
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull()
}
@Test
fun `watch - user was logging in, transaction is never started since it's not a cold start`() = runTest {
val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsColdStartWatcher(analyticsService)
// Start watching
watcher.start()
// If the user starts a login flow
watcher.whenLoggingIn()
// The user has given analytics consent, we can start tracking the cold start
analyticsService.setUserConsent(true)
runCurrent()
// The transaction never starts
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull()
}
@Test
fun `watch - never gets consent so it does nothing`() = runTest {
val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsColdStartWatcher(analyticsService)
// Start watching
watcher.start()
// The user never gets the analytics consent, so we do nothing
runCurrent()
// The transaction is not running in that case
assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull()
}
private fun TestScope.createAnalyticsColdStartWatcher(
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = DefaultAnalyticsColdStartWatcher(
analyticsService = analyticsService,
appCoroutineScope = backgroundScope,
)
}

View File

@@ -0,0 +1,170 @@
/*
* 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.impl.watchers
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultAnalyticsRoomListStateWatcherTest {
@Test
fun `Opening the app in a warm state tracks the time until the room list is synced`() = runTest {
val navigationStateService = FakeAppNavigationStateService()
val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Idle)
}
val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsRoomListStateWatcher(
appNavigationStateService = navigationStateService,
roomListService = roomListService,
analyticsService = analyticsService,
)
watcher.start()
// Give some time to load the initial state
runCurrent()
// Make sure it's warm by changing its internal state
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false))
runCurrent()
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
runCurrent()
// The transaction should be present now
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull()
// And now the room list service running
roomListService.postState(RoomListService.State.Running)
runCurrent()
// And the transaction should now be gone
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull()
watcher.stop()
}
@Test
fun `Opening the app in a cold state does nothing`() = runTest {
val navigationStateService = FakeAppNavigationStateService().apply {
appNavigationState.emit(AppNavigationState(NavigationState.Root, false))
}
val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Idle)
}
val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsRoomListStateWatcher(
appNavigationStateService = navigationStateService,
roomListService = roomListService,
analyticsService = analyticsService,
)
watcher.start()
// Give some time to load the initial state
runCurrent()
// The room list service running
roomListService.postState(RoomListService.State.Running)
runCurrent()
// The transaction was never present
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull()
watcher.stop()
}
@Test
fun `The transaction won't be finished until the room list is synchronised`() = runTest {
val navigationStateService = FakeAppNavigationStateService()
val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Idle)
}
val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsRoomListStateWatcher(
appNavigationStateService = navigationStateService,
roomListService = roomListService,
analyticsService = analyticsService,
)
watcher.start()
// Give some time to load the initial state
runCurrent()
// Make sure it's warm by changing its internal state
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false))
runCurrent()
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
runCurrent()
// The transaction should be present now
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull()
runCurrent()
// But without the room list syncing, it never finishes
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull()
watcher.stop()
}
@Test
fun `Opening the app when the room list state was already Running does nothing`() = runTest {
val navigationStateService = FakeAppNavigationStateService()
val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Running)
}
val analyticsService = FakeAnalyticsService()
val watcher = createAnalyticsRoomListStateWatcher(
appNavigationStateService = navigationStateService,
roomListService = roomListService,
analyticsService = analyticsService,
)
watcher.start()
// Give some time to load the initial state
runCurrent()
// Make sure it's warm by changing its internal state
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false))
runCurrent()
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
runCurrent()
// The transaction was never added
assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull()
watcher.stop()
}
private fun TestScope.createAnalyticsRoomListStateWatcher(
appNavigationStateService: FakeAppNavigationStateService = FakeAppNavigationStateService(),
roomListService: FakeRoomListService = FakeRoomListService(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = DefaultAnalyticsRoomListStateWatcher(
appNavigationStateService = appNavigationStateService,
roomListService = roomListService,
analyticsService = analyticsService,
sessionCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
)
}

View File

@@ -0,0 +1,26 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.services.analytics.noop"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.di)
api(projects.services.analytics.api)
testCommonDependencies(libs)
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.noop
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.SuperProperties
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.NoopAnalyticsTransaction
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.flowOf
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class NoopAnalyticsService : AnalyticsService {
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
override val userConsentFlow: Flow<Boolean> = flowOf(false)
override suspend fun setUserConsent(userConsent: Boolean) = Unit
override val didAskUserConsentFlow: Flow<Boolean> = flowOf(true)
override suspend fun setDidAskUserConsent() = Unit
override val analyticsIdFlow: Flow<String> = flowOf("")
override suspend fun setAnalyticsId(analyticsId: String) = Unit
override fun capture(event: VectorAnalyticsEvent) = Unit
override fun screen(screen: VectorAnalyticsScreen) = Unit
override fun updateUserProperties(userProperties: UserProperties) = Unit
override fun trackError(throwable: Throwable) = Unit
override fun updateSuperProperties(updatedProperties: SuperProperties) = Unit
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction
override fun startLongRunningTransaction(
longRunningTransaction: AnalyticsLongRunningTransaction,
parentTransaction: AnalyticsTransaction?,
): AnalyticsTransaction = NoopAnalyticsTransaction
override fun getLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? = null
override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) = NoopAnalyticsTransaction
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.noop
import androidx.compose.runtime.Composable
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.services.analytics.api.ScreenTracker
@ContributesBinding(AppScope::class)
class NoopScreenTracker : ScreenTracker {
@Composable
override fun TrackScreen(screen: MobileScreen.ScreenName) = Unit
}

View File

@@ -0,0 +1,19 @@
/*
* 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.noop.watchers
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
@ContributesBinding(AppScope::class)
class NoopAnalyticsColdStartWatcher : AnalyticsColdStartWatcher {
override fun start() {}
override fun whenLoggingIn() {}
override fun onRoomListVisible() {}
}

View File

@@ -0,0 +1,18 @@
/*
* 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.noop.watchers
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.watchers.AnalyticsRoomListStateWatcher
@ContributesBinding(SessionScope::class)
class NoopAnalyticsRoomListStateWatcher : AnalyticsRoomListStateWatcher {
override fun start() {}
override fun stop() {}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.noop
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CallStarted
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties
import kotlinx.coroutines.test.runTest
import org.junit.Test
class NoopAnalyticsServiceTest {
@Test
fun `getAvailableAnalyticsProviders returns emptySet`() {
val sut = NoopAnalyticsService()
assertThat(sut.getAvailableAnalyticsProviders()).isEmpty()
}
@Test
fun `didAskUserConsentFlow emits only true`() = runTest {
val sut = NoopAnalyticsService()
sut.didAskUserConsentFlow.test {
assertThat(awaitItem()).isTrue()
awaitComplete()
}
}
@Test
fun `analyticsIdFlow emits only empty string`() = runTest {
val sut = NoopAnalyticsService()
sut.analyticsIdFlow.test {
assertThat(awaitItem()).isEmpty()
sut.setAnalyticsId("anId")
awaitComplete()
}
}
@Test
fun `userConsentFlow emits only false`() = runTest {
val sut = NoopAnalyticsService()
sut.userConsentFlow.test {
assertThat(awaitItem()).isFalse()
awaitComplete()
}
}
@Test
fun `test no op methods`() = runTest {
val sut = NoopAnalyticsService()
sut.setUserConsent(false)
sut.setUserConsent(true)
sut.setDidAskUserConsent()
sut.setAnalyticsId("anId")
sut.capture(CallStarted(true, 1, true))
sut.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomMembers))
sut.updateUserProperties(UserProperties())
sut.trackError(Exception("an_error"))
sut.updateSuperProperties(SuperProperties())
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.noop
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 kotlinx.coroutines.test.runTest
import org.junit.Test
class NoopScreenTrackerTest {
@Test
fun `TrackScreen is no op`() = runTest {
val sut = NoopScreenTracker()
moleculeFlow(RecompositionMode.Immediate) {
sut.TrackScreen(MobileScreen.ScreenName.RoomMembers)
}.test {
assertThat(awaitItem()).isEqualTo(Unit)
}
}
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.services.analytics.test"
}
dependencies {
api(projects.services.analytics.api)
implementation(projects.libraries.core)
implementation(projects.tests.testutils)
implementation(libs.coroutines.core)
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.test
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.SuperProperties
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.NoopAnalyticsTransaction
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.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeAnalyticsService(
isEnabled: Boolean = false,
didAskUserConsent: Boolean = false,
) : AnalyticsService {
private val isEnabledFlow = MutableStateFlow(isEnabled)
override val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
val screenEvents = mutableListOf<VectorAnalyticsScreen>()
val trackedErrors = mutableListOf<Throwable>()
val capturedUserProperties = mutableListOf<UserProperties>()
val longRunningTransactions = mutableMapOf<AnalyticsLongRunningTransaction, AnalyticsTransaction>()
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
override val userConsentFlow: Flow<Boolean> = isEnabledFlow.asStateFlow()
override suspend fun setUserConsent(userConsent: Boolean) {
isEnabledFlow.value = userConsent
}
override suspend fun setDidAskUserConsent() {
didAskUserConsentFlow.value = true
}
override val analyticsIdFlow: Flow<String> = MutableStateFlow("")
override suspend fun setAnalyticsId(analyticsId: String) {
}
override fun capture(event: VectorAnalyticsEvent) {
capturedEvents += event
}
override fun screen(screen: VectorAnalyticsScreen) {
screenEvents += screen
}
override fun updateUserProperties(userProperties: UserProperties) {
capturedUserProperties += userProperties
}
override fun trackError(throwable: Throwable) {
trackedErrors += throwable
}
override fun updateSuperProperties(updatedProperties: SuperProperties) {
// No op
}
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction
override fun startLongRunningTransaction(
longRunningTransaction: AnalyticsLongRunningTransaction,
parentTransaction: AnalyticsTransaction?
): AnalyticsTransaction {
longRunningTransactions[longRunningTransaction] = NoopAnalyticsTransaction
return NoopAnalyticsTransaction
}
override fun getLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? {
return longRunningTransactions[longRunningTransaction]
}
override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? {
return longRunningTransactions.remove(longRunningTransaction)
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.test
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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) {
LaunchedEffect(Unit) {
trackScreenLambda(screen)
}
}
}

View File

@@ -0,0 +1,16 @@
/*
* 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.test.watchers
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
class FakeAnalyticsColdStartWatcher : AnalyticsColdStartWatcher {
override fun start() {}
override fun whenLoggingIn() {}
override fun onRoomListVisible() {}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.analyticsproviders.api"
}
dependencies {
api(libs.matrix.analytics.events)
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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
import io.element.android.services.analyticsproviders.api.trackers.AnalyticsTracker
import io.element.android.services.analyticsproviders.api.trackers.ErrorTracker
interface AnalyticsProvider : AnalyticsTracker, ErrorTracker {
/**
* User friendly name.
*/
val name: String
fun init()
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

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.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.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties
interface AnalyticsTracker {
/**
* Capture an Event.
*/
fun capture(event: VectorAnalyticsEvent)
/**
* Track a displayed screen.
*/
fun screen(screen: VectorAnalyticsScreen)
/**
* Update user specific properties.
*/
fun updateUserProperties(userProperties: UserProperties)
/**
* Update the super properties.
* Super properties are added to any tracked event automatically.
*/
fun updateSuperProperties(updatedProperties: SuperProperties)
}
fun AnalyticsTracker.captureInteraction(name: Interaction.Name, type: Interaction.InteractionType? = null) {
capture(Interaction(interactionType = type, name = name))
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.trackers
interface ErrorTracker {
fun trackError(throwable: Throwable)
}

View File

@@ -0,0 +1,48 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.analyticsproviders.posthog"
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigFieldStr(
name = "POSTHOG_HOST",
value = BuildTimeConfig.SERVICES_POSTHOG_HOST.takeIf { isEnterpriseBuild } ?: ""
)
buildConfigFieldStr(
name = "POSTHOG_APIKEY",
value = BuildTimeConfig.SERVICES_POSTHOG_APIKEY.takeIf { isEnterpriseBuild } ?: ""
)
}
}
setupDependencyInjection()
dependencies {
implementation(libs.posthog) {
exclude("com.android.support", "support-annotations")
}
implementation(projects.features.enterprise.api)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.services.analyticsproviders.api)
testCommonDependencies(libs)
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.posthog
import android.content.Context
import com.posthog.PostHogInterface
import com.posthog.android.PostHogAndroid
import com.posthog.android.PostHogAndroidConfig
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.annotations.ApplicationContext
@Inject
class PostHogFactory(
@ApplicationContext private val context: Context,
private val buildMeta: BuildMeta,
private val posthogEndpointConfigProvider: PosthogEndpointConfigProvider,
) {
fun createPosthog(): PostHogInterface? {
val endpoint = posthogEndpointConfigProvider.provide() ?: return null
return PostHogAndroid.with(
context,
PostHogAndroidConfig(
apiKey = endpoint.apiKey,
host = endpoint.host,
captureApplicationLifecycleEvents = false,
captureDeepLinks = false,
captureScreenViews = false,
).also {
it.debug = buildMeta.isDebuggable
it.sendFeatureFlagEvent = false
}
)
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.posthog
import com.posthog.PostHogInterface
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties
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 timber.log.Timber
// private val REUSE_EXISTING_ID: String? = null
// private val IGNORED_OPTIONS: Options? = null
@ContributesIntoSet(AppScope::class)
@Inject
class PosthogAnalyticsProvider(
private val postHogFactory: PostHogFactory,
) : AnalyticsProvider {
override val name = "Posthog"
private var posthog: PostHogInterface? = null
private var analyticsId: String? = null
private var pendingUserProperties: MutableMap<String, Any>? = null
private var superProperties: SuperProperties? = null
private val userPropertiesLock = Any()
override fun init() {
posthog = postHogFactory.createPosthog()
posthog?.optIn()
// Timber.e("PostHog distinctId: ${posthog?.distinctId()}")
identifyPostHog()
}
override fun stop() {
// When opting out, ensure that the queue is flushed first, or it will be flushed later (after user has revoked consent)
posthog?.flush()
posthog?.optOut()
posthog?.close()
posthog = null
analyticsId = null
}
override fun capture(event: VectorAnalyticsEvent) {
synchronized(userPropertiesLock) {
posthog?.capture(
event = event.getName(),
properties = event.getProperties()?.keepOnlyNonNullValues().withSuperProperties(),
userProperties = pendingUserProperties,
)
pendingUserProperties = null
}
}
override fun screen(screen: VectorAnalyticsScreen) {
posthog?.screen(
screenTitle = screen.getName(),
properties = screen.getProperties().withSuperProperties(),
)
}
override fun updateUserProperties(userProperties: UserProperties) {
synchronized(userPropertiesLock) {
// The pending properties will be sent with the following capture call
if (pendingUserProperties == null) {
pendingUserProperties = HashMap()
}
userProperties.getProperties()?.let {
pendingUserProperties?.putAll(it)
}
// We are not currently using `identify` in EAX, if it was the case
// we could have called identify to update the user properties.
// For now, we have to store them, and they will be updated when the next call
// to capture will happen.
}
}
override fun updateSuperProperties(updatedProperties: SuperProperties) {
this.superProperties = SuperProperties(
cryptoSDK = updatedProperties.cryptoSDK ?: this.superProperties?.cryptoSDK,
appPlatform = updatedProperties.appPlatform ?: this.superProperties?.appPlatform,
cryptoSDKVersion = updatedProperties.cryptoSDKVersion ?: superProperties?.cryptoSDKVersion
)
}
override fun trackError(throwable: Throwable) {
// Not implemented
}
private fun identifyPostHog() {
val id = analyticsId ?: return
if (id.isEmpty()) {
Timber.tag(analyticsTag.value).d("reset")
posthog?.reset()
} else {
Timber.tag(analyticsTag.value).d("identify")
// posthog?.identify(id, lateInitUserPropertiesFactory.createUserProperties()?.getProperties()?.toPostHogUserProperties(), IGNORED_OPTIONS)
}
}
private fun Map<String, Any>?.withSuperProperties(): Map<String, Any>? {
val withSuperProperties = this.orEmpty().toMutableMap()
val superProperties = this@PosthogAnalyticsProvider.superProperties?.getProperties()
superProperties?.forEach {
if (!withSuperProperties.containsKey(it.key)) {
withSuperProperties[it.key] = it.value
}
}
return withSuperProperties.takeIf { it.isEmpty().not() }
}
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? = null
}
private fun Map<String, Any?>.keepOnlyNonNullValues(): Map<String, Any> {
val result = mutableMapOf<String, Any>()
for (entry in this) {
val value = entry.value
if (value != null) {
result[entry.key] = value
}
}
return result
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.posthog
data class PosthogEndpointConfig(
val host: String,
val apiKey: String,
) {
val isValid = host.isNotBlank() && apiKey.isNotBlank()
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.posthog
import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.core.extensions.isElement
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
@Inject
class PosthogEndpointConfigProvider(
private val buildMeta: BuildMeta,
private val enterpriseService: EnterpriseService,
) {
fun provide(): PosthogEndpointConfig? {
return if (enterpriseService.isEnterpriseBuild) {
PosthogEndpointConfig(
host = BuildConfig.POSTHOG_HOST,
apiKey = BuildConfig.POSTHOG_APIKEY,
).takeIf {
// Note that if the config is invalid, this module will not be included in the build.
// So the configuration should be always valid.
it.isValid
}
} else if (buildMeta.isElement()) {
when (buildMeta.buildType) {
BuildType.RELEASE -> PosthogEndpointConfig(
host = "https://posthog.element.io",
apiKey = "phc_Jzsm6DTm6V2705zeU5dcNvQDlonOR68XvX2sh1sEOHO",
)
BuildType.NIGHTLY,
BuildType.DEBUG -> PosthogEndpointConfig(
host = "https://posthog.element.dev",
apiKey = "phc_VtA1L35nw3aeAtHIx1ayrGdzGkss7k1xINeXcoIQzXN",
)
}
} else {
null
}
}
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector 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.posthog.extensions
import im.vector.app.features.analytics.plan.Interaction
fun Interaction.Name.toAnalyticsInteraction(interactionType: Interaction.InteractionType = Interaction.InteractionType.Touch) =
Interaction(
name = this,
interactionType = interactionType
)

View File

@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2021-2025 New Vector 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.posthog.log
import io.element.android.libraries.core.log.logger.LoggerTag
internal val analyticsTag = LoggerTag("Posthog")

View File

@@ -0,0 +1,219 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.posthog
import com.google.common.truth.Truth.assertThat
import com.posthog.PostHogInterface
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class PosthogAnalyticsProviderTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `Posthog - Test user properties`() = runTest {
val mockPosthog = mockk<PostHogInterface>().also {
every { it.optIn() } just runs
every { it.capture(any(), any(), any(), any(), any(), any()) } just runs
}
val mockPosthogFactory = mockk<PostHogFactory>()
every { mockPosthogFactory.createPosthog() } returns mockPosthog
val analyticsProvider = PosthogAnalyticsProvider(mockPosthogFactory)
analyticsProvider.init()
val testUserProperties = UserProperties(
verificationState = UserProperties.VerificationState.Verified,
recoveryState = UserProperties.RecoveryState.Incomplete,
)
analyticsProvider.updateUserProperties(testUserProperties)
// report mock event
val mockEvent = mockk<VectorAnalyticsEvent>().also {
every {
it.getProperties()
} returns emptyMap()
every { it.getName() } returns "MockEventName"
}
analyticsProvider.capture(mockEvent)
val userPropertiesSlot = slot<Map<String, Any>>()
verify { mockPosthog.capture(event = "MockEventName", any(), any(), userProperties = capture(userPropertiesSlot)) }
assertThat(userPropertiesSlot.captured).isNotNull()
assertThat(userPropertiesSlot.captured["verificationState"] as String).isEqualTo(testUserProperties.verificationState?.name)
assertThat(userPropertiesSlot.captured["recoveryState"] as String).isEqualTo(testUserProperties.recoveryState?.name)
// Should only be reported once when the next event is sent
// Try another capture to check
analyticsProvider.capture(mockEvent)
verify { mockPosthog.capture(any(), any(), any(), userProperties = null) }
}
@Test
fun `Posthog - Test accumulate user properties until next capture call`() = runTest {
val mockPosthog = mockk<PostHogInterface>().also {
every { it.optIn() } just runs
every { it.capture(any(), any(), any(), any(), any(), any()) } just runs
}
val mockPosthogFactory = mockk<PostHogFactory>()
every { mockPosthogFactory.createPosthog() } returns mockPosthog
val analyticsProvider = PosthogAnalyticsProvider(mockPosthogFactory)
analyticsProvider.init()
val testUserProperties = UserProperties(
verificationState = UserProperties.VerificationState.NotVerified,
)
analyticsProvider.updateUserProperties(testUserProperties)
// Update again
val testUserPropertiesUpdate = UserProperties(
verificationState = UserProperties.VerificationState.Verified,
)
analyticsProvider.updateUserProperties(testUserPropertiesUpdate)
// report mock event
val mockEvent = mockk<VectorAnalyticsEvent>().also {
every {
it.getProperties()
} returns emptyMap()
every { it.getName() } returns "MockEventName"
}
analyticsProvider.capture(mockEvent)
val userPropertiesSlot = slot<Map<String, Any>>()
verify { mockPosthog.capture(event = "MockEventName", any(), any(), userProperties = capture(userPropertiesSlot)) }
assertThat(userPropertiesSlot.captured).isNotNull()
assertThat(userPropertiesSlot.captured["verificationState"] as String).isEqualTo(testUserPropertiesUpdate.verificationState?.name)
}
@Test
fun `Posthog - Test super properties added to all captured events`() = runTest {
val mockPosthog = mockk<PostHogInterface>().also {
every { it.optIn() } just runs
every { it.capture(any(), any(), any(), any(), any(), any()) } just runs
every { it.screen(any(), any()) } just runs
}
val mockPosthogFactory = mockk<PostHogFactory>()
every { mockPosthogFactory.createPosthog() } returns mockPosthog
val analyticsProvider = PosthogAnalyticsProvider(mockPosthogFactory)
analyticsProvider.init()
val testSuperProperties = SuperProperties(
appPlatform = SuperProperties.AppPlatform.EXA,
)
analyticsProvider.updateSuperProperties(testSuperProperties)
// Test with events having different sort of properties
listOf(
mapOf("foo" to "bar"),
mapOf("a" to "aValue1", "b" to "aValue2"),
null
).forEach { eventProperties ->
// report an event with properties
val mockEvent = mockk<VectorAnalyticsEvent>().also {
every {
it.getProperties()
} returns eventProperties
every { it.getName() } returns "MockEventName"
}
analyticsProvider.capture(mockEvent)
val expectedProperties = eventProperties.orEmpty() + testSuperProperties.getProperties().orEmpty()
verify { mockPosthog.capture(event = "MockEventName", any(), properties = expectedProperties, any()) }
}
// / Test it is also added to screens
val screenEvent = MobileScreen(null, MobileScreen.ScreenName.Home)
analyticsProvider.screen(screenEvent)
verify { mockPosthog.screen(MobileScreen.ScreenName.Home.rawValue, testSuperProperties.getProperties()) }
}
@Test
fun `Posthog - Test super properties can be updated`() = runTest {
val mockPosthog = mockk<PostHogInterface>().also {
every { it.optIn() } just runs
every { it.capture(any(), any(), any(), any(), any(), any()) } just runs
}
val mockPosthogFactory = mockk<PostHogFactory>()
every { mockPosthogFactory.createPosthog() } returns mockPosthog
val analyticsProvider = PosthogAnalyticsProvider(mockPosthogFactory)
analyticsProvider.init()
// Test with events having different sort of aggregation
// left is the updated properties, right is the expected aggregated state
mapOf(
SuperProperties(appPlatform = SuperProperties.AppPlatform.EXA) to SuperProperties(appPlatform = SuperProperties.AppPlatform.EXA),
SuperProperties(cryptoSDKVersion = "0.0") to SuperProperties(appPlatform = SuperProperties.AppPlatform.EXA, cryptoSDKVersion = "0.0"),
SuperProperties(cryptoSDKVersion = "1.0") to SuperProperties(appPlatform = SuperProperties.AppPlatform.EXA, cryptoSDKVersion = "1.0"),
SuperProperties(cryptoSDK = SuperProperties.CryptoSDK.Rust) to SuperProperties(
appPlatform = SuperProperties.AppPlatform.EXA,
cryptoSDKVersion = "1.0",
cryptoSDK = SuperProperties.CryptoSDK.Rust
),
).entries.forEach { (updated, expected) ->
// report an event with properties
val mockEvent = mockk<VectorAnalyticsEvent>().also {
every {
it.getProperties()
} returns null
every { it.getName() } returns "MockEventName"
}
analyticsProvider.updateSuperProperties(updated)
analyticsProvider.capture(mockEvent)
verify { mockPosthog.capture(event = "MockEventName", any(), properties = expected.getProperties(), any()) }
}
}
@Test
fun `Posthog - Test super properties do not override property with same name on the event`() = runTest {
val mockPosthog = mockk<PostHogInterface>().also {
every { it.optIn() } just runs
every { it.capture(any(), any(), any(), any(), any(), any()) } just runs
}
val mockPosthogFactory = mockk<PostHogFactory>()
every { mockPosthogFactory.createPosthog() } returns mockPosthog
val analyticsProvider = PosthogAnalyticsProvider(mockPosthogFactory)
analyticsProvider.init()
// report an event with properties
val mockEvent = mockk<VectorAnalyticsEvent>().also {
every {
it.getProperties()
} returns mapOf("appPlatform" to SuperProperties.AppPlatform.Other)
every { it.getName() } returns "MockEventName"
}
analyticsProvider.updateSuperProperties(SuperProperties(appPlatform = SuperProperties.AppPlatform.EXA))
analyticsProvider.capture(mockEvent)
verify { mockPosthog.capture(event = "MockEventName", any(), properties = mapOf("appPlatform" to SuperProperties.AppPlatform.Other), any()) }
}
}

View File

@@ -0,0 +1,45 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.readLocalProperty
import extension.setupDependencyInjection
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.analyticsproviders.sentry"
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigFieldStr(
name = "SENTRY_DSN",
value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_SENTRY_DSN
} else {
System.getenv("ELEMENT_ANDROID_SENTRY_DSN")
?: readLocalProperty("services.analyticsproviders.sentry.dsn")
}
?: ""
)
}
}
setupDependencyInjection()
dependencies {
implementation(libs.sentry)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.services.analyticsproviders.api)
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2025 Element Creations Ltd.
~ Copyright 2023 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Sentry auto-initialization disabled -->
<meta-data
android:name="io.sentry.auto-init"
android:value="false" />
</application>
</manifest>

View File

@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.annotations.ApplicationContext
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.sentry.Breadcrumb
import io.sentry.Sentry
import io.sentry.SentryOptions
import io.sentry.android.core.SentryAndroid
import timber.log.Timber
@ContributesIntoSet(AppScope::class)
@Inject
class SentryAnalyticsProvider(
@ApplicationContext private val context: Context,
private val buildMeta: BuildMeta,
) : AnalyticsProvider {
override val name = SentryConfig.NAME
override fun init() {
Timber.tag(analyticsTag.value).d("Initializing Sentry")
if (Sentry.isEnabled()) return
val dsn = SentryConfig.DSN.ifBlank {
Timber.w("No Sentry DSN provided, Sentry will not be initialized")
return
}
SentryAndroid.init(context) { options ->
options.dsn = dsn
options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> event }
options.tracesSampleRate = 1.0
options.isEnableUserInteractionTracing = true
options.environment = buildMeta.buildType.toSentryEnv()
}
Timber.tag(analyticsTag.value).d("Sentry was initialized correctly")
}
override fun stop() {
Timber.tag(analyticsTag.value).d("Stopping Sentry")
Sentry.close()
}
override fun capture(event: VectorAnalyticsEvent) {
val breadcrumb = Breadcrumb(event.getName()).apply {
category = "event"
for ((key, value) in event.getProperties().orEmpty()) {
setData(key, value.toString())
}
}
Sentry.addBreadcrumb(breadcrumb)
}
override fun screen(screen: VectorAnalyticsScreen) {
val breadcrumb = Breadcrumb(screen.getName()).apply {
category = "screen"
for ((key, value) in screen.getProperties().orEmpty()) {
setData(key, value.toString())
}
}
Sentry.addBreadcrumb(breadcrumb)
}
override fun updateUserProperties(userProperties: UserProperties) {
}
override fun updateSuperProperties(updatedProperties: SuperProperties) {
}
override fun trackError(throwable: Throwable) {
Sentry.captureException(throwable)
}
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? {
return SentryAnalyticsTransaction(name, operation)
}
}
private fun BuildType.toSentryEnv() = when (this) {
BuildType.RELEASE -> SentryConfig.ENV_RELEASE
BuildType.NIGHTLY -> SentryConfig.ENV_NIGHTLY
BuildType.DEBUG -> SentryConfig.ENV_DEBUG
}

View File

@@ -0,0 +1,30 @@
/*
* 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.ITransaction
import io.sentry.Sentry
import timber.log.Timber
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() {
val name = if (inner is ITransaction) inner.name else inner.operation
Timber.d("Finishing transaction: $name")
inner.finish()
}
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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
object SentryConfig {
const val NAME = "Sentry"
const val DSN = BuildConfig.SENTRY_DSN
const val ENV_DEBUG = "DEBUG"
const val ENV_NIGHTLY = "NIGHTLY"
const val ENV_RELEASE = "RELEASE"
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.log
import io.element.android.libraries.core.log.logger.LoggerTag
internal val analyticsTag = LoggerTag("Sentry")

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.analyticsproviders.test"
}
dependencies {
implementation(projects.services.analyticsproviders.api)
implementation(projects.tests.testutils)
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.test
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.SuperProperties
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
import io.element.android.tests.testutils.lambda.lambdaError
class FakeAnalyticsProvider(
override val name: String = "FakeAnalyticsProvider",
private val initLambda: () -> Unit = { lambdaError() },
private val stopLambda: () -> Unit = { lambdaError() },
private val captureLambda: (VectorAnalyticsEvent) -> Unit = { lambdaError() },
private val screenLambda: (VectorAnalyticsScreen) -> Unit = { lambdaError() },
private val updateUserPropertiesLambda: (UserProperties) -> Unit = { lambdaError() },
private val updateSuperPropertiesLambda: (SuperProperties) -> Unit = { lambdaError() },
private val trackErrorLambda: (Throwable) -> Unit = { lambdaError() }
) : AnalyticsProvider {
override fun init() = initLambda()
override fun stop() = stopLambda()
override fun capture(event: VectorAnalyticsEvent) = captureLambda(event)
override fun screen(screen: VectorAnalyticsScreen) = screenLambda(screen)
override fun updateUserProperties(userProperties: UserProperties) = updateUserPropertiesLambda(userProperties)
override fun trackError(throwable: Throwable) = trackErrorLambda(throwable)
override fun updateSuperProperties(updatedProperties: SuperProperties) = updateSuperPropertiesLambda(updatedProperties)
override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? = null
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.services.apperror.api"
}
dependencies {
implementation(libs.coroutines.core)
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.apperror.api
import androidx.compose.runtime.Immutable
@Immutable
sealed interface AppErrorState {
data object NoError : AppErrorState
data class Error(
val title: String,
val body: String,
val dismiss: () -> Unit,
) : AppErrorState
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.apperror.api
fun aAppErrorState() = AppErrorState.Error(
title = "An error occurred",
body = "Something went wrong, and the details of that would go here.",
dismiss = {},
)

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.apperror.api
import androidx.annotation.StringRes
import kotlinx.coroutines.flow.StateFlow
interface AppErrorStateService {
val appErrorStateFlow: StateFlow<AppErrorState>
fun showError(title: String, body: String)
fun showError(@StringRes titleRes: Int, @StringRes bodyRes: Int)
}

View File

@@ -0,0 +1,36 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
setupDependencyInjection()
android {
namespace = "io.element.android.services.apperror.impl"
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
implementation(libs.coroutines.core)
implementation(libs.androidx.corektx)
api(projects.services.apperror.api)
testCommonDependencies(libs)
testImplementation(projects.services.toolbox.test)
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.apperror.impl
import androidx.compose.runtime.Composable
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.aAppErrorState
@Composable
fun AppErrorView(
state: AppErrorState,
) {
if (state is AppErrorState.Error) {
AppErrorViewContent(
title = state.title,
body = state.body,
onDismiss = state.dismiss,
)
}
}
@Composable
private fun AppErrorViewContent(
title: String,
body: String,
onDismiss: () -> Unit = { },
) {
ErrorDialog(
title = title,
content = body,
onSubmit = onDismiss,
)
}
@PreviewsDayNight
@Composable
internal fun AppErrorViewPreview() = ElementPreview {
AppErrorView(
state = aAppErrorState()
)
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.apperror.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultAppErrorStateService(
private val stringProvider: StringProvider,
) : AppErrorStateService {
private val currentAppErrorState = MutableStateFlow<AppErrorState>(AppErrorState.NoError)
override val appErrorStateFlow: StateFlow<AppErrorState> = currentAppErrorState
override fun showError(title: String, body: String) {
currentAppErrorState.value = AppErrorState.Error(
title = title,
body = body,
dismiss = {
currentAppErrorState.value = AppErrorState.NoError
},
)
}
override fun showError(titleRes: Int, bodyRes: Int) {
val title = stringProvider.getString(titleRes)
val body = stringProvider.getString(bodyRes)
showError(title, body)
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.apperror.impl
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class DefaultAppErrorStateServiceTest {
@Test
fun `initial value is no error`() = runTest {
val service = createDefaultAppErrorStateService()
service.appErrorStateFlow.test {
val state = awaitItem()
assertThat(state).isInstanceOf(AppErrorState.NoError::class.java)
}
}
@Test
fun `showError - emits value`() = runTest {
val service = createDefaultAppErrorStateService()
service.appErrorStateFlow.test {
skipItems(1)
service.showError("Title", "Body")
val state = awaitItem()
assertThat(state).isInstanceOf(AppErrorState.Error::class.java)
val errorState = state as AppErrorState.Error
assertThat(errorState.title).isEqualTo("Title")
assertThat(errorState.body).isEqualTo("Body")
}
}
@Test
fun `showError - emits value from ids`() = runTest {
val service = createDefaultAppErrorStateService()
service.appErrorStateFlow.test {
skipItems(1)
service.showError(1, 2)
val state = awaitItem()
assertThat(state).isInstanceOf(AppErrorState.Error::class.java)
val errorState = state as AppErrorState.Error
assertThat(errorState.title).isEqualTo("A string")
assertThat(errorState.body).isEqualTo("A string")
}
}
@Test
fun `dismiss - clears value`() = runTest {
val service = createDefaultAppErrorStateService()
service.appErrorStateFlow.test {
skipItems(1)
service.showError("Title", "Body")
val state = awaitItem()
assertThat(state).isInstanceOf(AppErrorState.Error::class.java)
val errorState = state as AppErrorState.Error
errorState.dismiss()
assertThat(awaitItem()).isInstanceOf(AppErrorState.NoError::class.java)
}
}
private fun createDefaultAppErrorStateService() = DefaultAppErrorStateService(
stringProvider = FakeStringProvider(),
)
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.apperror.test"
}
dependencies {
implementation(libs.coroutines.core)
implementation(projects.services.apperror.api)
implementation(projects.tests.testutils)
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.apperror.test
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeAppErrorStateService(
initialState: AppErrorState = AppErrorState.NoError,
private val showErrorResult: (String, String) -> Unit = { _, _ -> lambdaError() },
private val showErrorResResult: (Int, Int) -> Unit = { _, _ -> lambdaError() }
) : AppErrorStateService {
private val mutableAppErrorStateFlow = MutableStateFlow(initialState)
override val appErrorStateFlow: StateFlow<AppErrorState> = mutableAppErrorStateFlow.asStateFlow()
override fun showError(title: String, body: String) {
showErrorResult(title, body)
}
override fun showError(titleRes: Int, bodyRes: Int) {
showErrorResResult(titleRes, bodyRes)
}
fun setAppErrorState(state: AppErrorState) {
mutableAppErrorStateFlow.value = state
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.appnavstate.api"
}
dependencies {
implementation(libs.coroutines.core)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.startup)
implementation(projects.libraries.matrix.api)
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
/**
* Holds the active rooms for a given session so they can be reused instead of instantiating new ones.
*/
interface ActiveRoomsHolder {
/**
* Adds a new held room for the given sessionId.
*/
fun addRoom(room: JoinedRoom)
/**
* Returns the last room added for the given [sessionId] or null if no room was added.
*/
fun getActiveRoom(sessionId: SessionId): JoinedRoom?
/**
* Returns an active room associated to the given [sessionId], with the given [roomId], or null if none match.
*/
fun getActiveRoomMatching(sessionId: SessionId, roomId: RoomId): JoinedRoom?
/**
* Removes any room matching the provided [sessionId] and [roomId].
*/
fun removeRoom(sessionId: SessionId, roomId: RoomId)
/**
* Clears all the rooms for the given sessionId.
*/
fun clear(sessionId: SessionId)
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.api
import kotlinx.coroutines.flow.StateFlow
/**
* A service that tracks the foreground state of the app.
*/
interface AppForegroundStateService {
/**
* Any updates to the foreground state of the app will be emitted here.
*/
val isInForeground: StateFlow<Boolean>
/**
* Updates to whether the app is active because an incoming ringing call is happening will be emitted here.
*/
val hasRingingCall: StateFlow<Boolean>
/**
* Updates to whether the app is in an active call or not will be emitted here.
*/
val isInCall: StateFlow<Boolean>
/**
* Updates to whether the app is syncing a notification event or not will be emitted here.
*/
val isSyncingNotificationEvent: StateFlow<Boolean>
/**
* Start observing the foreground state.
*/
fun startObservingForeground()
/**
* Update the in-call state.
*/
fun updateIsInCallState(isInCall: Boolean)
/**
* Update the 'has ringing call' state.
*/
fun updateHasRingingCall(hasRingingCall: Boolean)
/**
* Update the active state for the syncing notification event flow.
*/
fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean)
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.api
/**
* A wrapper for the current navigation state of the app, along with its foreground/background state.
*/
data class AppNavigationState(
val navigationState: NavigationState,
val isInForeground: Boolean,
)

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import kotlinx.coroutines.flow.StateFlow
/**
* A service that tracks the navigation and foreground states of the app.
*/
interface AppNavigationStateService {
val appNavigationState: StateFlow<AppNavigationState>
fun onNavigateToSession(owner: String, sessionId: SessionId)
fun onLeavingSession(owner: String)
fun onNavigateToSpace(owner: String, spaceId: SpaceId)
fun onLeavingSpace(owner: String)
fun onNavigateToRoom(owner: String, roomId: RoomId)
fun onLeavingRoom(owner: String)
fun onNavigateToThread(owner: String, threadId: ThreadId)
fun onLeavingThread(owner: String)
}

View File

@@ -0,0 +1,10 @@
/*
* 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.appnavstate.api
const val ROOM_OPENED_FROM_NOTIFICATION = "opened_from_notification"

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
/**
* Can represent the current global app navigation state.
* @param owner mostly a Node identifier associated with the state.
* We are using the owner parameter to check when calling onLeaving methods is still using the same owner than his companion onNavigate.
* Why this is needed : for now we rely on lifecycle methods of the node, which are async.
* If you navigate quickly between nodes, onCreate of the new node is called before onDestroy of the previous node.
* So we assume if we don't get the same owner, we can skip the onLeaving action as we already replaced it.
*/
sealed class NavigationState(open val owner: String) {
data object Root : NavigationState("ROOT")
data class Session(
override val owner: String,
val sessionId: SessionId,
) : NavigationState(owner)
data class Space(
override val owner: String,
// Can be fake value, if no space is selected
val spaceId: SpaceId,
val parentSession: Session,
) : NavigationState(owner)
data class Room(
override val owner: String,
val roomId: RoomId,
val parentSpace: Space,
) : NavigationState(owner)
data class Thread(
override val owner: String,
val threadId: ThreadId,
val parentRoom: Room,
) : NavigationState(owner)
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
fun NavigationState.currentSessionId(): SessionId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> sessionId
is NavigationState.Space -> parentSession.sessionId
is NavigationState.Room -> parentSpace.parentSession.sessionId
is NavigationState.Thread -> parentRoom.parentSpace.parentSession.sessionId
}
}
fun NavigationState.currentSpaceId(): SpaceId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> null
is NavigationState.Space -> spaceId
is NavigationState.Room -> parentSpace.spaceId
is NavigationState.Thread -> parentRoom.parentSpace.spaceId
}
}
fun NavigationState.currentRoomId(): RoomId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> null
is NavigationState.Space -> null
is NavigationState.Room -> roomId
is NavigationState.Thread -> parentRoom.roomId
}
}
fun NavigationState.currentThreadId(): ThreadId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> null
is NavigationState.Space -> null
is NavigationState.Room -> null
is NavigationState.Thread -> threadId
}
}

View File

@@ -0,0 +1,36 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
setupDependencyInjection()
android {
namespace = "io.element.android.services.appnavstate.impl"
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
implementation(libs.coroutines.core)
implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.process)
api(projects.services.appnavstate.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.appnavstate.test)
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.appnavstate.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import java.util.concurrent.ConcurrentHashMap
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultActiveRoomsHolder : ActiveRoomsHolder {
private val rooms = ConcurrentHashMap<SessionId, MutableSet<JoinedRoom>>()
override fun addRoom(room: JoinedRoom) {
val roomsForSessionId = rooms.getOrPut(key = room.sessionId, defaultValue = { mutableSetOf() })
if (roomsForSessionId.none { it.roomId == room.roomId }) {
// We don't want to add the same room multiple times
roomsForSessionId.add(room)
}
}
override fun getActiveRoom(sessionId: SessionId): JoinedRoom? {
return rooms[sessionId]?.lastOrNull()
}
override fun getActiveRoomMatching(sessionId: SessionId, roomId: RoomId): JoinedRoom? {
return rooms[sessionId]?.find { it.roomId == roomId }
}
override fun removeRoom(sessionId: SessionId, roomId: RoomId) {
val roomsForSessionId = rooms[sessionId] ?: return
roomsForSessionId.removeIf { it.roomId == roomId }
}
override fun clear(sessionId: SessionId) {
val activeRooms = rooms.remove(sessionId) ?: return
for (room in activeRooms) {
// Destroy the room to reset the live timelines
room.destroy()
}
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.impl
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.flow.MutableStateFlow
class DefaultAppForegroundStateService : AppForegroundStateService {
override val isInForeground = MutableStateFlow(false)
override val isInCall = MutableStateFlow(false)
override val isSyncingNotificationEvent = MutableStateFlow(false)
override val hasRingingCall = MutableStateFlow(false)
private val appLifecycle: Lifecycle by lazy { ProcessLifecycleOwner.get().lifecycle }
override fun startObservingForeground() {
appLifecycle.addObserver(lifecycleObserver)
}
override fun updateIsInCallState(isInCall: Boolean) {
this.isInCall.value = isInCall
}
override fun updateHasRingingCall(hasRingingCall: Boolean) {
this.hasRingingCall.value = hasRingingCall
}
override fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean) {
this.isSyncingNotificationEvent.value = isSyncingNotificationEvent
}
private val lifecycleObserver = LifecycleEventObserver { _, _ -> isInForeground.value = getCurrentState() }
private fun getCurrentState(): Boolean = appLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}

View File

@@ -0,0 +1,172 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.launch
import timber.log.Timber
private val loggerTag = LoggerTag("Navigation")
/**
* TODO This will maybe not support properly navigation using permalink.
*/
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultAppNavigationStateService(
private val appForegroundStateService: AppForegroundStateService,
@AppCoroutineScope
coroutineScope: CoroutineScope,
) : AppNavigationStateService {
private val state = MutableStateFlow(
AppNavigationState(
navigationState = NavigationState.Root,
isInForeground = true,
)
)
override val appNavigationState: StateFlow<AppNavigationState> = state
init {
coroutineScope.launch {
appForegroundStateService.startObservingForeground()
appForegroundStateService.isInForeground.collect { isInForeground ->
state.getAndUpdate { it.copy(isInForeground = isInForeground) }
}
}
}
override fun onNavigateToSession(owner: String, sessionId: SessionId) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to session $sessionId. Current state: $currentValue")
val newValue: NavigationState.Session = when (currentValue) {
is NavigationState.Session,
is NavigationState.Space,
is NavigationState.Room,
is NavigationState.Thread,
is NavigationState.Root -> NavigationState.Session(owner, sessionId)
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onNavigateToSpace(owner: String, spaceId: SpaceId) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to space $spaceId. Current state: $currentValue")
val newValue: NavigationState.Space = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> NavigationState.Space(owner, spaceId, currentValue)
is NavigationState.Space -> NavigationState.Space(owner, spaceId, currentValue.parentSession)
is NavigationState.Room -> NavigationState.Space(owner, spaceId, currentValue.parentSpace.parentSession)
is NavigationState.Thread -> NavigationState.Space(owner, spaceId, currentValue.parentRoom.parentSpace.parentSession)
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onNavigateToRoom(owner: String, roomId: RoomId) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to room $roomId. Current state: $currentValue")
val newValue: NavigationState.Room = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> NavigationState.Room(owner, roomId, currentValue)
is NavigationState.Room -> NavigationState.Room(owner, roomId, currentValue.parentSpace)
is NavigationState.Thread -> NavigationState.Room(owner, roomId, currentValue.parentRoom.parentSpace)
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onNavigateToThread(owner: String, threadId: ThreadId) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to thread $threadId. Current state: $currentValue")
val newValue: NavigationState.Thread = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Room -> NavigationState.Thread(owner, threadId, currentValue)
is NavigationState.Thread -> NavigationState.Thread(owner, threadId, currentValue.parentRoom)
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onLeavingThread(owner: String) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving thread. Current state: $currentValue")
if (!currentValue.assertOwner(owner)) return
val newValue: NavigationState.Room = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Room -> return logError("onNavigateToThread()")
is NavigationState.Thread -> currentValue.parentRoom
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onLeavingRoom(owner: String) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving room. Current state: $currentValue")
if (!currentValue.assertOwner(owner)) return
val newValue: NavigationState.Space = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Room -> currentValue.parentSpace
is NavigationState.Thread -> currentValue.parentRoom.parentSpace
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onLeavingSpace(owner: String) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving space. Current state: $currentValue")
if (!currentValue.assertOwner(owner)) return
val newValue: NavigationState.Session = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> currentValue.parentSession
is NavigationState.Room -> currentValue.parentSpace.parentSession
is NavigationState.Thread -> currentValue.parentRoom.parentSpace.parentSession
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onLeavingSession(owner: String) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving session. Current state: $currentValue")
if (!currentValue.assertOwner(owner)) return
state.getAndUpdate { it.copy(navigationState = NavigationState.Root) }
}
private fun logError(logPrefix: String) {
Timber.tag(loggerTag.value).w("$logPrefix must be call first.")
}
private fun NavigationState.assertOwner(owner: String): Boolean {
if (this.owner != owner) {
Timber.tag(loggerTag.value).d("Can't leave current state as the owner is not the same (current = ${this.owner}, new = $owner)")
return false
}
return true
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.impl.di
import android.content.Context
import androidx.startup.AppInitializer
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.appnavstate.impl.initializer.AppForegroundStateServiceInitializer
@BindingContainer
@ContributesTo(AppScope::class)
object AppNavStateModule {
@Provides
fun provideAppForegroundStateService(
@ApplicationContext context: Context
): AppForegroundStateService =
AppInitializer.getInstance(context).initializeComponent(AppForegroundStateServiceInitializer::class.java)
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.impl.initializer
import android.content.Context
import androidx.lifecycle.ProcessLifecycleInitializer
import androidx.startup.Initializer
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.appnavstate.impl.DefaultAppForegroundStateService
class AppForegroundStateServiceInitializer : Initializer<AppForegroundStateService> {
override fun create(context: Context): AppForegroundStateService {
return DefaultAppForegroundStateService()
}
override fun dependencies(): MutableList<Class<out Initializer<*>>> = mutableListOf(
ProcessLifecycleInitializer::class.java
)
}

View File

@@ -0,0 +1,340 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_SPACE_ID
import io.element.android.libraries.matrix.test.A_SPACE_ID_2
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID_2
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.test.A_ROOM_OWNER
import io.element.android.services.appnavstate.test.A_SESSION_OWNER
import io.element.android.services.appnavstate.test.A_SPACE_OWNER
import io.element.android.services.appnavstate.test.A_THREAD_OWNER
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultNavigationStateServiceTest {
private val navigationStateRoot = NavigationState.Root
private val navigationStateSession = NavigationState.Session(
owner = A_SESSION_OWNER,
sessionId = A_SESSION_ID
)
private val navigationStateSpace = NavigationState.Space(
owner = A_SPACE_OWNER,
spaceId = A_SPACE_ID,
parentSession = navigationStateSession
)
private val navigationStateRoom = NavigationState.Room(
owner = A_ROOM_OWNER,
roomId = A_ROOM_ID,
parentSpace = navigationStateSpace
)
private val navigationStateThread = NavigationState.Thread(
owner = A_THREAD_OWNER,
threadId = A_THREAD_ID,
parentRoom = navigationStateRoom
)
@Test
fun testNavigation() = runTest {
val service = createStateService()
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
// Leaving the states
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
}
@Test
fun testFailure() = runTest {
val service = createStateService()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.value.navigationState).isEqualTo(NavigationState.Root)
}
@Test
fun testOnNavigateToThread() = runTest {
val service = createStateService()
// From root (no effect)
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room
service.reset()
service.navigateToRoom()
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
// From thread
service.reset()
service.navigateToThread()
// Navigate to another thread
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID_2)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread.copy(threadId = A_THREAD_ID_2))
}
@Test
fun testOnNavigateToRoom() = runTest {
val service = createStateService()
// From root (no effect)
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space
service.reset()
service.navigateToSpace()
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
// From room
service.reset()
service.navigateToRoom()
// Navigate to another room
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID_2)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom.copy(roomId = A_ROOM_ID_2))
// From thread
service.reset()
service.navigateToThread()
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
}
@Test
fun testOnNavigateToSpace() = runTest {
val service = createStateService()
// From root (no effect)
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session
service.reset()
service.navigateToSession()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From space
service.reset()
service.navigateToSpace()
// Navigate to another space
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID_2)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace.copy(spaceId = A_SPACE_ID_2))
// From room (no effect)
service.reset()
service.navigateToRoom()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From thread (no effect)
service.reset()
service.navigateToThread()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
}
@Test
fun testOnNavigateToSession() = runTest {
val service = createStateService()
// From root
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From session
service.reset()
service.navigateToSession()
// Navigate to another session
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID_2)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession.copy(sessionId = A_SESSION_ID_2))
// From space
service.reset()
service.navigateToSpace()
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From room
service.reset()
service.navigateToRoom()
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From thread
service.reset()
service.navigateToThread()
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
}
@Test
fun testOnLeavingThread() = runTest {
val service = createStateService()
// From root (no effect)
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room (no effect)
service.reset()
service.navigateToRoom()
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
// From thread
service.reset()
service.navigateToThread()
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
}
@Test
fun testOnLeavingRoom() = runTest {
val service = createStateService()
// From root (no effect)
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room
service.reset()
service.navigateToRoom()
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From thread (no effect)
service.reset()
service.navigateToThread()
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
}
@Test
fun testOnLeavingSpace() = runTest {
val service = createStateService()
// From root (no effect)
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space
service.reset()
service.navigateToSpace()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From room (no effect)
service.reset()
service.navigateToRoom()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
// From thread (no effect)
service.reset()
service.navigateToThread()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
}
@Test
fun testOnLeavingSession() = runTest {
val service = createStateService()
// From root
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session
service.reset()
service.navigateToSession()
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room (no effect)
service.reset()
service.navigateToRoom()
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
// From thread (no effect)
service.reset()
service.navigateToThread()
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
}
private fun AppNavigationStateService.reset() {
navigateToSession()
onLeavingSession(A_SESSION_OWNER)
}
private fun AppNavigationStateService.navigateToSession() {
onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
}
private fun AppNavigationStateService.navigateToSpace() {
navigateToSession()
onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
}
private fun AppNavigationStateService.navigateToRoom() {
navigateToSpace()
onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
}
private fun AppNavigationStateService.navigateToThread() {
navigateToRoom()
onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
}
private fun TestScope.createStateService() = DefaultAppNavigationStateService(
appForegroundStateService = FakeAppForegroundStateService(),
coroutineScope = backgroundScope,
)
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.appnavstate.test"
}
dependencies {
api(projects.libraries.matrix.api)
api(projects.services.appnavstate.api)
implementation(libs.coroutines.core)
implementation(libs.androidx.lifecycle.runtime)
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.test
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.services.appnavstate.api.NavigationState
const val A_SESSION_OWNER = "aSessionOwner"
const val A_SPACE_OWNER = "aSpaceOwner"
const val A_ROOM_OWNER = "aRoomOwner"
const val A_THREAD_OWNER = "aThreadOwner"
fun aNavigationState(
sessionId: SessionId? = null,
spaceId: SpaceId? = MAIN_SPACE,
roomId: RoomId? = null,
threadId: ThreadId? = null,
): NavigationState {
if (sessionId == null) {
return NavigationState.Root
}
val session = NavigationState.Session(A_SESSION_OWNER, sessionId)
if (spaceId == null) {
return session
}
val space = NavigationState.Space(A_SPACE_OWNER, spaceId, session)
if (roomId == null) {
return space
}
val room = NavigationState.Room(A_ROOM_OWNER, roomId, space)
if (threadId == null) {
return room
}
return NavigationState.Thread(A_THREAD_OWNER, threadId, room)
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.test
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.flow.MutableStateFlow
class FakeAppForegroundStateService(
initialForegroundValue: Boolean = true,
initialIsInCallValue: Boolean = false,
initialIsSyncingNotificationEventValue: Boolean = false,
initialHasRingingCall: Boolean = false,
) : AppForegroundStateService {
override val isInForeground = MutableStateFlow(initialForegroundValue)
override val isInCall = MutableStateFlow(initialIsInCallValue)
override val isSyncingNotificationEvent = MutableStateFlow(initialIsSyncingNotificationEventValue)
override val hasRingingCall = MutableStateFlow(initialHasRingingCall)
override fun startObservingForeground() {
// No-op
}
fun givenIsInForeground(isInForeground: Boolean) {
this.isInForeground.value = isInForeground
}
override fun updateIsInCallState(isInCall: Boolean) {
this.isInCall.value = isInCall
}
override fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean) {
this.isSyncingNotificationEvent.value = isSyncingNotificationEvent
}
override fun updateHasRingingCall(hasRingingCall: Boolean) {
this.hasRingingCall.value = hasRingingCall
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.appnavstate.test
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import kotlinx.coroutines.flow.MutableStateFlow
class FakeAppNavigationStateService(
override val appNavigationState: MutableStateFlow<AppNavigationState> = MutableStateFlow(
AppNavigationState(
navigationState = NavigationState.Root,
isInForeground = true,
)
),
) : AppNavigationStateService {
override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit
override fun onLeavingSession(owner: String) = Unit
override fun onNavigateToSpace(owner: String, spaceId: SpaceId) = Unit
override fun onLeavingSpace(owner: String) = Unit
override fun onNavigateToRoom(owner: String, roomId: RoomId) = Unit
override fun onLeavingRoom(owner: String) = Unit
override fun onNavigateToThread(owner: String, threadId: ThreadId) = Unit
override fun onLeavingThread(owner: String) = Unit
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.toolbox.api"
}
dependencies {
implementation(libs.androidx.corektx)
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.toolbox.api.intent
import android.content.Intent
/**
* Used to launch external intents from anywhere in the app.
*/
interface ExternalIntentLauncher {
fun launch(intent: Intent)
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.toolbox.api.sdk
import androidx.annotation.ChecksSdkIntAtLeast
interface BuildVersionSdkIntProvider {
/**
* Return the current version of the Android SDK.
*/
fun get(): Int
/**
* Checks the if the current OS version is equal or greater than [version].
* @return A `non-null` result if true, `null` otherwise.
*/
@ChecksSdkIntAtLeast(parameter = 0, lambda = 1)
fun <T> whenAtLeast(version: Int, result: () -> T): T? {
return if (get() >= version) {
result()
} else {
null
}
}
@ChecksSdkIntAtLeast(parameter = 0)
fun isAtLeast(version: Int) = get() >= version
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.toolbox.api.strings
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
interface StringProvider {
/**
* Returns a localized string from the application's package's
* default string table.
*
* @param resId Resource id for the string
* @return The string data associated with the resource, stripped of styled
* text information.
*/
fun getString(@StringRes resId: Int): String
/**
* Returns a localized formatted string from the application's package's
* default string table, substituting the format arguments as defined in
* [java.util.Formatter] and [java.lang.String.format].
*
* @param resId Resource id for the format string
* @param formatArgs The format arguments that will be used for
* substitution.
* @return The string data associated with the resource, formatted and
* stripped of styled text information.
*/
fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String
fun getQuantityString(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any?): String
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.toolbox.api.systemclock
fun interface SystemClock {
fun epochMillis(): Long
}

View File

@@ -0,0 +1,25 @@
import extension.setupDependencyInjection
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.toolbox.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.di)
api(projects.services.toolbox.api)
implementation(libs.androidx.corektx)
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.toolbox.impl.intent
import android.content.Context
import android.content.Intent
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
@ContributesBinding(AppScope::class)
class DefaultExternalIntentLauncher(
@ApplicationContext private val context: Context,
) : ExternalIntentLauncher {
override fun launch(intent: Intent) {
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.toolbox.impl.sdk
import android.os.Build
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@ContributesBinding(AppScope::class)
class DefaultBuildVersionSdkIntProvider :
BuildVersionSdkIntProvider {
override fun get() = Build.VERSION.SDK_INT
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.toolbox.impl.strings
import android.content.res.Resources
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.services.toolbox.api.strings.StringProvider
@ContributesBinding(AppScope::class)
class AndroidStringProvider(private val resources: Resources) : StringProvider {
override fun getString(@StringRes resId: Int): String {
return resources.getString(resId)
}
override fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String {
return resources.getString(resId, *formatArgs)
}
override fun getQuantityString(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any?): String {
return resources.getQuantityString(resId, quantity, *formatArgs)
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.toolbox.impl.systemclock
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.services.toolbox.api.systemclock.SystemClock
@ContributesBinding(AppScope::class)
class DefaultSystemClock : SystemClock {
/**
* Provides a UTC epoch in milliseconds
*
* This value is not guaranteed to be correct with reality
* as a User can override the system time and date to any values.
*/
override fun epochMillis(): Long {
return System.currentTimeMillis()
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.toolbox.impl.systemclock
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import kotlin.time.TimeSource
@BindingContainer
@ContributesTo(AppScope::class)
object TimeModule {
@Provides
fun timeSource(): TimeSource {
return TimeSource.Monotonic
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.toolbox.test"
}
dependencies {
api(projects.services.toolbox.api)
implementation(projects.tests.testutils)
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.toolbox.test.intent
import android.content.Intent
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
import io.element.android.tests.testutils.lambda.lambdaError
class FakeExternalIntentLauncher(
var launchLambda: (Intent) -> Unit = { lambdaError() },
) : ExternalIntentLauncher {
override fun launch(intent: Intent) {
launchLambda(intent)
}
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.toolbox.test.sdk
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
class FakeBuildVersionSdkIntProvider(
private val sdkInt: Int
) : BuildVersionSdkIntProvider {
override fun get(): Int = sdkInt
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.toolbox.test.strings
import io.element.android.services.toolbox.api.strings.StringProvider
class FakeStringProvider(
private val defaultResult: String = "A string"
) : StringProvider {
var lastResIdParam: Int? = null
override fun getString(resId: Int): String {
lastResIdParam = resId
return defaultResult
}
override fun getString(resId: Int, vararg formatArgs: Any?): String {
lastResIdParam = resId
return defaultResult + formatArgs.joinToString()
}
override fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any?): String {
lastResIdParam = resId
return defaultResult + " ($quantity) " + formatArgs.joinToString()
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.toolbox.test.systemclock
import io.element.android.services.toolbox.api.systemclock.SystemClock
const val A_FAKE_TIMESTAMP = 123L
class FakeSystemClock(
var epochMillisResult: Long = A_FAKE_TIMESTAMP
) : SystemClock {
override fun epochMillis() = epochMillisResult
}