forked from dsutanto/bChot-android
First Commit
This commit is contained in:
21
services/analytics/api/build.gradle.kts
Normal file
21
services/analytics/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
18
services/analytics/compose/build.gradle.kts
Normal file
18
services/analytics/compose/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
44
services/analytics/impl/build.gradle.kts
Normal file
44
services/analytics/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
26
services/analytics/noop/build.gradle.kts
Normal file
26
services/analytics/noop/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
services/analytics/test/build.gradle.kts
Normal file
21
services/analytics/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
18
services/analyticsproviders/api/build.gradle.kts
Normal file
18
services/analyticsproviders/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
48
services/analyticsproviders/posthog/build.gradle.kts
Normal file
48
services/analyticsproviders/posthog/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
45
services/analyticsproviders/sentry/build.gradle.kts
Normal file
45
services/analyticsproviders/sentry/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
19
services/analyticsproviders/test/build.gradle.kts
Normal file
19
services/analyticsproviders/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
19
services/apperror/api/build.gradle.kts
Normal file
19
services/apperror/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
36
services/apperror/impl/build.gradle.kts
Normal file
36
services/apperror/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
21
services/apperror/test/build.gradle.kts
Normal file
21
services/apperror/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
23
services/appnavstate/api/build.gradle.kts
Normal file
23
services/appnavstate/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
36
services/appnavstate/impl/build.gradle.kts
Normal file
36
services/appnavstate/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
22
services/appnavstate/test/build.gradle.kts
Normal file
22
services/appnavstate/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
18
services/toolbox/api/build.gradle.kts
Normal file
18
services/toolbox/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
25
services/toolbox/impl/build.gradle.kts
Normal file
25
services/toolbox/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
19
services/toolbox/test/build.gradle.kts
Normal file
19
services/toolbox/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user