From a078851b78c885467b555940a9c07f8eebe8f03f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 13:23:21 +0200 Subject: [PATCH 01/13] Add small script to force stop an application. --- tools/adb/force_stop.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100755 tools/adb/force_stop.sh diff --git a/tools/adb/force_stop.sh b/tools/adb/force_stop.sh new file mode 100755 index 0000000000..aaf9274010 --- /dev/null +++ b/tools/adb/force_stop.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026 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_name="io.element.android.x.debug" + +echo " => Force stop ${package_name}" + +adb shell am force-stop "${package_name}" From d471241da871d72e9c5c904d383161777ebe4dfb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 13:57:55 +0200 Subject: [PATCH 02/13] Fix account provisioning deep link being ignored when it cold starts the app At cold start two writers set the root nav target of RootFlowNode: the intent path (handleIntent -> onLoginLink -> NotLoggedInFlow(params)) and the first nav state emission (NotLoggedIn -> NotLoggedInFlow(null)). safeRoot() only dedupes on strict equality, so the two targets do not cancel each other out and the last writer wins, silently dropping the login params. Keep the params coming from the intent as pending state on the node, and let the nav state observer take them into account, so that the root is derived from both the logged in state and the pending login params, and both orderings converge on the same target. The pending params are cleared when leaving the not logged in flow, so that a later sign out does not resurrect a stale provisioning link. Also log when switching to the not logged in flow, since the drop was silent. The login hint can contain an email address, so only its presence is logged. Closes #7482 Co-Authored-By: Claude Opus 5 (1M context) --- appnav/build.gradle.kts | 3 + .../io/element/android/appnav/RootFlowNode.kt | 14 +- .../android/appnav/RootFlowNodeTest.kt | 267 ++++++++++++++++++ 3 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 054e9525c7..64aa2a6fc5 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -63,11 +63,14 @@ dependencies { testImplementation(projects.features.share.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.matrixmedia.test) testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.features.announcement.test) testImplementation(projects.features.forward.test) testImplementation(projects.features.messages.test) testImplementation(projects.features.networkmonitor.test) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index acf7b66db9..19ec427303 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -109,6 +109,13 @@ class RootFlowNode( buildContext = buildContext, plugins = plugins ) { + /** + * Login params coming from a launch or new [Intent], waiting to be consumed by the not logged in flow. + * Kept here so that the root nav target can be computed from both the logged in state and the pending + * login params, whatever the order in which the intent and the first nav state emission are processed. + */ + private var pendingLoginParams: LoginParams? = null + override fun onBuilt() { analyticsColdStartWatcher.start() appCoroutineScope.launch { @@ -152,7 +159,7 @@ class RootFlowNode( } } LoggedInState.NotLoggedIn -> { - switchToNotLoggedInFlow(null) + switchToNotLoggedInFlow(pendingLoginParams) } } } @@ -194,15 +201,18 @@ class RootFlowNode( } private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { + pendingLoginParams = null backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) } private fun switchToNotLoggedInFlow(params: LoginParams?) { + Timber.d("switchToNotLoggedInFlow, hasLoginParams=${params != null}") matrixSessionCache.removeAll() backstack.safeRoot(NavTarget.NotLoggedInFlow(params)) } private fun switchToSignedOutFlow(sessionId: SessionId) { + pendingLoginParams = null backstack.safeRoot(NavTarget.SignedOutFlow(sessionId)) } @@ -319,6 +329,7 @@ class RootFlowNode( } override fun onDone() { + pendingLoginParams = null backstack.pop() } } @@ -418,6 +429,7 @@ class RootFlowNode( Timber.w("Login link ignored, multi account is disabled") } } else { + pendingLoginParams = params switchToNotLoggedInFlow(params) } } else { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt new file mode 100644 index 0000000000..e749ffa492 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2026 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.appnav + +import android.content.Intent +import android.net.Uri +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.AncestryInfo +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.node.node +import com.bumble.appyx.navmodel.backstack.activeElement +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper +import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl +import com.google.common.truth.Truth.assertThat +import io.element.android.appnav.di.MatrixSessionCache +import io.element.android.appnav.di.SyncOrchestrator +import io.element.android.appnav.intent.IntentResolver +import io.element.android.appnav.root.RootNavStateFlowFactory +import io.element.android.appnav.root.RootPresenter +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.features.login.api.LoginParams +import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.features.login.test.FakeLoginIntentResolver +import io.element.android.features.preferences.api.CacheService +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.features.share.test.FakeShareIntentHandler +import io.element.android.features.signedout.api.SignedOutEntryPoint +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.libraries.architecture.AssistedNodeFactory +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.deeplink.api.DeeplinkParser +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.FakeSdkMetadata +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.oauth.api.OAuthAction +import io.element.android.libraries.oauth.api.OAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver +import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher +import io.element.android.services.apperror.test.FakeAppErrorStateService +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.reflect.KClass + +class RootFlowNodeTest : RobolectricTest() { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `given no session, when a login link is handled before the nav state is observed, then the login params are kept`() = runTest { + val loginEntryPoint = FakeLoginEntryPoint() + val rootFlowNode = createRootFlowNode(loginEntryPoint = loginEntryPoint) + rootFlowNode.parentNodeTestHelper() + // The intent is handled first, this is what happens when the deep link cold starts the app. + rootFlowNode.handleIntent(aLoginIntent()) + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) + // Then the first nav state emission lands, it must not override the login params. + runCurrent() + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) + assertThat(loginEntryPoint.params).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) + } + + @Test + fun `given no session, when a login link is handled after the nav state is observed, then the login params are applied`() = runTest { + val loginEntryPoint = FakeLoginEntryPoint() + val rootFlowNode = createRootFlowNode(loginEntryPoint = loginEntryPoint) + rootFlowNode.parentNodeTestHelper() + // The nav state is observed first, this is what happens when the app is already running. + runCurrent() + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(null)) + rootFlowNode.handleIntent(aLoginIntent()) + runCurrent() + assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) + assertThat(loginEntryPoint.params).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) + } + + private fun aLoginIntent() = Intent(Intent.ACTION_VIEW, Uri.parse(A_LOGIN_LINK)) + + private fun TestScope.createRootFlowNode( + loginEntryPoint: LoginEntryPoint = FakeLoginEntryPoint(), + sessionStore: SessionStore = InMemorySessionStore(), + ): RootFlowNode { + val matrixSessionCache = MatrixSessionCache( + authenticationService = FakeMatrixAuthenticationService(), + syncOrchestratorFactory = FakeSyncOrchestratorFactory(), + analyticsService = FakeAnalyticsService(), + ) + val parentNode = FakeParentNode( + graph = FakeNodeFactoriesBindings( + mapOf( + NotLoggedInFlowNode::class to AssistedNodeFactory { buildContext, plugins -> + NotLoggedInFlowNode( + buildContext = buildContext, + plugins = plugins, + loginEntryPoint = loginEntryPoint, + imageLoaderHolder = FakeImageLoaderHolder(), + analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + ) + } + ) + ) + ) + return RootFlowNode( + buildContext = BuildContext( + ancestryInfo = AncestryInfo.Child(anchor = parentNode), + savedStateMap = null, + customisations = NodeCustomisationDirectoryImpl(), + ), + plugins = emptyList(), + sessionStore = sessionStore, + accountProviderAccessControl = FakeAccountProviderAccessControl(), + navStateFlowFactory = RootNavStateFlowFactory( + sessionStore = sessionStore, + cacheService = FakeCacheService(), + matrixSessionCache = matrixSessionCache, + imageLoaderHolder = FakeImageLoaderHolder(), + sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(), + ), + matrixSessionCache = matrixSessionCache, + presenter = RootPresenter( + crashDetectionPresenter = NotUsedPresenter(), + rageshakeDetectionPresenter = NotUsedPresenter(), + appErrorStateService = FakeAppErrorStateService(), + analyticsService = FakeAnalyticsService(), + sdkMetadata = FakeSdkMetadata("sha"), + ), + bugReportEntryPoint = FakeBugReportEntryPoint(), + signedOutEntryPoint = FakeSignedOutEntryPoint(), + accountSelectEntryPoint = FakeAccountSelectEntryPoint(), + intentResolver = IntentResolver( + deeplinkParser = DeeplinkParser { null }, + loginIntentResolver = FakeLoginIntentResolver { A_LOGIN_PARAMS }, + oAuthIntentResolver = FakeOAuthIntentResolver { null }, + permalinkParser = FakePermalinkParser(), + shareIntentHandler = FakeShareIntentHandler(), + ), + oAuthActionFlow = FakeOAuthActionFlow(), + featureFlagService = FakeFeatureFlagService(), + announcementService = FakeAnnouncementService(), + analyticsService = FakeAnalyticsService(), + analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + appCoroutineScope = backgroundScope, + ) + } +} + +private const val A_LOGIN_LINK = "https://mobile.element.io/element/?account_provider=example.com&login_hint=mxid:@alice:example.com" + +private val A_LOGIN_PARAMS = LoginParams( + accountProvider = "example.com", + loginHint = "mxid:@alice:example.com", +) + +private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( + accountProvider = A_LOGIN_PARAMS.accountProvider, + loginHint = A_LOGIN_PARAMS.loginHint, +) + +/** + * The presenters are only used when the [Node] is rendered, which does not happen in these tests. + */ +private class NotUsedPresenter : Presenter { + @Composable + override fun present(): T = error("Not used") +} + +private class FakeCacheService : CacheService { + override val clearedCacheEventFlow = emptyFlow() +} + +private class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { + override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope): SyncOrchestrator = error("Not used") +} + +private class FakeOAuthActionFlow : OAuthActionFlow { + override fun post(oAuthAction: OAuthAction) = Unit + override suspend fun collect(collector: FlowCollector) = Unit + override fun reset() = Unit +} + +private class FakeAccountProviderAccessControl : AccountProviderAccessControl { + override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = true +} + +private class FakeBugReportEntryPoint : BugReportEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext, callback: BugReportEntryPoint.Callback): Node = node(buildContext) {} +} + +private class FakeSignedOutEntryPoint : SignedOutEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext, params: SignedOutEntryPoint.Params): Node = node(buildContext) {} +} + +private class FakeAccountSelectEntryPoint : AccountSelectEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext, callback: AccountSelectEntryPoint.Callback): Node = node(buildContext) {} +} + +private class FakeLoginEntryPoint : LoginEntryPoint { + var params: LoginEntryPoint.Params? = null + private set + + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: LoginEntryPoint.Params, + callback: LoginEntryPoint.Callback, + ): Node { + this.params = params + return node(buildContext) {} + } +} + +private class FakeNodeFactoriesBindings( + private val nodeFactories: Map, AssistedNodeFactory<*>>, +) : NodeFactoriesBindings { + override fun nodeFactories() = nodeFactories +} + +/** + * [RootFlowNode] creates its children using the dependency injection graph of its ancestors, so it needs a parent + * owning a graph able to create a [NotLoggedInFlowNode]. + */ +private class FakeParentNode( + override val graph: Any, +) : ParentNode( + navModel = PermanentNavModel(navTargets = setOf(Unit), savedStateMap = null), + buildContext = BuildContext.root(savedStateMap = null), +), + DependencyInjectionGraphOwner { + override fun resolve(navTarget: Unit, buildContext: BuildContext): Node = node(buildContext) {} + + @Composable + override fun View(modifier: Modifier) = Unit +} From 8d4c7c868ebdeb8b551776e0d3f432adbdd889cc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 14:20:20 +0200 Subject: [PATCH 03/13] Cleanup test --- .../test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index e749ffa492..9710b72df7 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -43,7 +43,6 @@ import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.architecture.AssistedNodeFactory import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.deeplink.api.DeeplinkParser import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient @@ -162,7 +161,7 @@ class RootFlowNodeTest : RobolectricTest() { signedOutEntryPoint = FakeSignedOutEntryPoint(), accountSelectEntryPoint = FakeAccountSelectEntryPoint(), intentResolver = IntentResolver( - deeplinkParser = DeeplinkParser { null }, + deeplinkParser = { null }, loginIntentResolver = FakeLoginIntentResolver { A_LOGIN_PARAMS }, oAuthIntentResolver = FakeOAuthIntentResolver { null }, permalinkParser = FakePermalinkParser(), From b3f53da7d99454f25708c0e091b3331555adb270 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 14:55:03 +0200 Subject: [PATCH 04/13] Add exception for Konsist test. --- .../io/element/android/tests/konsist/KonsistClassNameTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index 93b3200acc..76d0dfd582 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -104,6 +104,7 @@ class KonsistClassNameTest { "FakeImageLoader", "FakeKeyStore", "FakeListenableFuture", + "FakeParentNode", ) .assertTrue { val interfaceName = it.name From f0e575e25efb45aa1d2eb48dd7a7259d11787a7e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:11:00 +0200 Subject: [PATCH 05/13] Move the fake entry points of RootFlowNodeTest to the test modules The fakes were declared in RootFlowNodeTest, so other tests could not use them. Move each of them next to the API it fakes, following the existing convention of a createNodeResult lambda defaulting to lambdaError(): - FakeLoginEntryPoint in features/login/test - FakeBugReportEntryPoint in features/rageshake/test - FakeSignedOutEntryPoint in features/signedout/test (new module) - FakeAccountSelectEntryPoint in libraries/accountselect/test (new module) The login and rageshake test modules did not have Appyx on their classpath, so add the architecture module to their dependencies. Co-Authored-By: Claude Opus 5 (1M context) --- appnav/build.gradle.kts | 2 + .../android/appnav/RootFlowNodeTest.kt | 58 +++++++------------ features/login/test/build.gradle.kts | 1 + .../login/test/FakeLoginEntryPoint.kt | 24 ++++++++ features/rageshake/test/build.gradle.kts | 1 + .../rageshake/test/FakeBugReportEntryPoint.kt | 23 ++++++++ features/signedout/test/build.gradle.kts | 19 ++++++ .../signedout/test/FakeSignedOutEntryPoint.kt | 23 ++++++++ libraries/accountselect/test/build.gradle.kts | 19 ++++++ .../test/FakeAccountSelectEntryPoint.kt | 23 ++++++++ 10 files changed, 156 insertions(+), 37 deletions(-) create mode 100644 features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt create mode 100644 features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt create mode 100644 features/signedout/test/build.gradle.kts create mode 100644 features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt create mode 100644 libraries/accountselect/test/build.gradle.kts create mode 100644 libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 64aa2a6fc5..8e21f247ee 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { testCommonDependencies(libs) testImplementation(projects.features.login.test) testImplementation(projects.features.share.test) + testImplementation(projects.libraries.accountselect.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrixmedia.test) @@ -75,6 +76,7 @@ dependencies { testImplementation(projects.features.messages.test) testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.rageshake.test) + testImplementation(projects.features.signedout.test) testImplementation(projects.services.apperror.test) testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.analytics.test) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 9710b72df7..1661782871 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -33,13 +33,14 @@ import io.element.android.appnav.root.RootPresenter import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.features.login.test.FakeLoginEntryPoint import io.element.android.features.login.test.FakeLoginIntentResolver import io.element.android.features.preferences.api.CacheService -import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.rageshake.test.FakeBugReportEntryPoint import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.features.share.test.FakeShareIntentHandler -import io.element.android.features.signedout.api.SignedOutEntryPoint -import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.features.signedout.test.FakeSignedOutEntryPoint +import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint import io.element.android.libraries.architecture.AssistedNodeFactory import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.architecture.Presenter @@ -81,8 +82,13 @@ class RootFlowNodeTest : RobolectricTest() { @Test fun `given no session, when a login link is handled before the nav state is observed, then the login params are kept`() = runTest { - val loginEntryPoint = FakeLoginEntryPoint() - val rootFlowNode = createRootFlowNode(loginEntryPoint = loginEntryPoint) + var loginEntryPointParams: LoginEntryPoint.Params? = null + val rootFlowNode = createRootFlowNode( + loginEntryPoint = FakeLoginEntryPoint { buildContext, params -> + loginEntryPointParams = params + node(buildContext) {} + }, + ) rootFlowNode.parentNodeTestHelper() // The intent is handled first, this is what happens when the deep link cold starts the app. rootFlowNode.handleIntent(aLoginIntent()) @@ -90,13 +96,18 @@ class RootFlowNodeTest : RobolectricTest() { // Then the first nav state emission lands, it must not override the login params. runCurrent() assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) - assertThat(loginEntryPoint.params).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) + assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) } @Test fun `given no session, when a login link is handled after the nav state is observed, then the login params are applied`() = runTest { - val loginEntryPoint = FakeLoginEntryPoint() - val rootFlowNode = createRootFlowNode(loginEntryPoint = loginEntryPoint) + var loginEntryPointParams: LoginEntryPoint.Params? = null + val rootFlowNode = createRootFlowNode( + loginEntryPoint = FakeLoginEntryPoint { buildContext, params -> + loginEntryPointParams = params + node(buildContext) {} + }, + ) rootFlowNode.parentNodeTestHelper() // The nav state is observed first, this is what happens when the app is already running. runCurrent() @@ -104,13 +115,13 @@ class RootFlowNodeTest : RobolectricTest() { rootFlowNode.handleIntent(aLoginIntent()) runCurrent() assertThat(rootFlowNode.backstack.activeElement).isEqualTo(RootFlowNode.NavTarget.NotLoggedInFlow(A_LOGIN_PARAMS)) - assertThat(loginEntryPoint.params).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) + assertThat(loginEntryPointParams).isEqualTo(A_LOGIN_ENTRY_POINT_PARAMS) } private fun aLoginIntent() = Intent(Intent.ACTION_VIEW, Uri.parse(A_LOGIN_LINK)) private fun TestScope.createRootFlowNode( - loginEntryPoint: LoginEntryPoint = FakeLoginEntryPoint(), + loginEntryPoint: LoginEntryPoint, sessionStore: SessionStore = InMemorySessionStore(), ): RootFlowNode { val matrixSessionCache = MatrixSessionCache( @@ -215,33 +226,6 @@ private class FakeAccountProviderAccessControl : AccountProviderAccessControl { override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = true } -private class FakeBugReportEntryPoint : BugReportEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext, callback: BugReportEntryPoint.Callback): Node = node(buildContext) {} -} - -private class FakeSignedOutEntryPoint : SignedOutEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext, params: SignedOutEntryPoint.Params): Node = node(buildContext) {} -} - -private class FakeAccountSelectEntryPoint : AccountSelectEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext, callback: AccountSelectEntryPoint.Callback): Node = node(buildContext) {} -} - -private class FakeLoginEntryPoint : LoginEntryPoint { - var params: LoginEntryPoint.Params? = null - private set - - override fun createNode( - parentNode: Node, - buildContext: BuildContext, - params: LoginEntryPoint.Params, - callback: LoginEntryPoint.Callback, - ): Node { - this.params = params - return node(buildContext) {} - } -} - private class FakeNodeFactoriesBindings( private val nodeFactories: Map, AssistedNodeFactory<*>>, ) : NodeFactoriesBindings { diff --git a/features/login/test/build.gradle.kts b/features/login/test/build.gradle.kts index 7073648dd2..4cbf0910db 100644 --- a/features/login/test/build.gradle.kts +++ b/features/login/test/build.gradle.kts @@ -15,6 +15,7 @@ android { } dependencies { + implementation(projects.libraries.architecture) implementation(projects.features.login.api) implementation(projects.tests.testutils) } diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt new file mode 100644 index 0000000000..2c94144890 --- /dev/null +++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginEntryPoint.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 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.features.login.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeLoginEntryPoint( + private val createNodeResult: (BuildContext, LoginEntryPoint.Params) -> Node = { _, _ -> lambdaError() }, +) : LoginEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: LoginEntryPoint.Params, + callback: LoginEntryPoint.Callback, + ): Node = createNodeResult(buildContext, params) +} diff --git a/features/rageshake/test/build.gradle.kts b/features/rageshake/test/build.gradle.kts index afc5e51499..faad812d73 100644 --- a/features/rageshake/test/build.gradle.kts +++ b/features/rageshake/test/build.gradle.kts @@ -14,6 +14,7 @@ android { } dependencies { + implementation(projects.libraries.architecture) implementation(projects.features.rageshake.api) implementation(libs.coroutines.core) implementation(projects.tests.testutils) diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt new file mode 100644 index 0000000000..d6e1a47368 --- /dev/null +++ b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/FakeBugReportEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 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.features.rageshake.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeBugReportEntryPoint( + private val createNodeResult: (BuildContext, BugReportEntryPoint.Callback) -> Node = { _, _ -> lambdaError() }, +) : BugReportEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: BugReportEntryPoint.Callback, + ): Node = createNodeResult(buildContext, callback) +} diff --git a/features/signedout/test/build.gradle.kts b/features/signedout/test/build.gradle.kts new file mode 100644 index 0000000000..5bbfb33c1a --- /dev/null +++ b/features/signedout/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.signedout.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.features.signedout.api) + implementation(projects.tests.testutils) +} diff --git a/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt b/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt new file mode 100644 index 0000000000..863b946871 --- /dev/null +++ b/features/signedout/test/src/main/kotlin/io/element/android/features/signedout/test/FakeSignedOutEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 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.features.signedout.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.signedout.api.SignedOutEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSignedOutEntryPoint( + private val createNodeResult: (BuildContext, SignedOutEntryPoint.Params) -> Node = { _, _ -> lambdaError() }, +) : SignedOutEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: SignedOutEntryPoint.Params, + ): Node = createNodeResult(buildContext, params) +} diff --git a/libraries/accountselect/test/build.gradle.kts b/libraries/accountselect/test/build.gradle.kts new file mode 100644 index 0000000000..c38cd8ac69 --- /dev/null +++ b/libraries/accountselect/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.accountselect.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.accountselect.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt b/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt new file mode 100644 index 0000000000..acc5ba10df --- /dev/null +++ b/libraries/accountselect/test/src/main/kotlin/io/element/android/libraries/accountselect/test/FakeAccountSelectEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 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.libraries.accountselect.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAccountSelectEntryPoint( + private val createNodeResult: (BuildContext, AccountSelectEntryPoint.Callback) -> Node = { _, _ -> lambdaError() }, +) : AccountSelectEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: AccountSelectEntryPoint.Callback, + ): Node = createNodeResult(buildContext, callback) +} From 67500edafa1fbe12e9b632be0beb60969517c0ce Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:17:18 +0200 Subject: [PATCH 06/13] Move FakeCacheService to the preferences test module The fake was declared in RootFlowNodeTest, so other tests could not use it. Move it next to the API it fakes, and let the cleared cache event flow be provided by the caller, so that a test can emit events. Co-Authored-By: Claude Opus 5 (1M context) --- appnav/build.gradle.kts | 1 + .../element/android/appnav/RootFlowNodeTest.kt | 8 +------- features/preferences/test/build.gradle.kts | 2 ++ .../preferences/test/FakeCacheService.kt | 17 +++++++++++++++++ 4 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 8e21f247ee..12b95d9a09 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { testImplementation(projects.features.forward.test) testImplementation(projects.features.messages.test) testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.features.preferences.test) testImplementation(projects.features.rageshake.test) testImplementation(projects.features.signedout.test) testImplementation(projects.services.apperror.test) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 1661782871..9cba1d7934 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -35,7 +35,7 @@ import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.test.FakeLoginEntryPoint import io.element.android.features.login.test.FakeLoginIntentResolver -import io.element.android.features.preferences.api.CacheService +import io.element.android.features.preferences.test.FakeCacheService import io.element.android.features.rageshake.test.FakeBugReportEntryPoint import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.features.share.test.FakeShareIntentHandler @@ -47,7 +47,6 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser @@ -65,7 +64,6 @@ import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -208,10 +206,6 @@ private class NotUsedPresenter : Presenter { override fun present(): T = error("Not used") } -private class FakeCacheService : CacheService { - override val clearedCacheEventFlow = emptyFlow() -} - private class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope): SyncOrchestrator = error("Not used") } diff --git a/features/preferences/test/build.gradle.kts b/features/preferences/test/build.gradle.kts index a066fe4707..5b8969796a 100644 --- a/features/preferences/test/build.gradle.kts +++ b/features/preferences/test/build.gradle.kts @@ -16,5 +16,7 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.features.preferences.api) + implementation(projects.libraries.matrix.api) + implementation(libs.coroutines.core) implementation(projects.tests.testutils) } diff --git a/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt b/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt new file mode 100644 index 0000000000..9696a2050b --- /dev/null +++ b/features/preferences/test/src/main/kotlin/io/element/android/features/preferences/test/FakeCacheService.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 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.features.preferences.test + +import io.element.android.features.preferences.api.CacheService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +class FakeCacheService( + override val clearedCacheEventFlow: Flow = emptyFlow(), +) : CacheService From 5bdaea3f6e0bb541e0b45a264728672952387124 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:20:33 +0200 Subject: [PATCH 07/13] Move FakeAccountProviderAccessControl to the login test module The fake was declared in RootFlowNodeTest, so other tests could not use it. Move it next to the API it fakes, in a package mirroring the one of the API, and let the result be provided by the caller, so that the case where the account provider is not allowed can be tested too. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/RootFlowNodeTest.kt | 8 ++------ .../FakeAccountProviderAccessControl.kt | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 9cba1d7934..7562356ffd 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -32,9 +32,9 @@ import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams -import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.test.FakeLoginEntryPoint import io.element.android.features.login.test.FakeLoginIntentResolver +import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl import io.element.android.features.preferences.test.FakeCacheService import io.element.android.features.rageshake.test.FakeBugReportEntryPoint import io.element.android.features.rageshake.test.logs.FakeAnnouncementService @@ -150,7 +150,7 @@ class RootFlowNodeTest : RobolectricTest() { ), plugins = emptyList(), sessionStore = sessionStore, - accountProviderAccessControl = FakeAccountProviderAccessControl(), + accountProviderAccessControl = FakeAccountProviderAccessControl { true }, navStateFlowFactory = RootNavStateFlowFactory( sessionStore = sessionStore, cacheService = FakeCacheService(), @@ -216,10 +216,6 @@ private class FakeOAuthActionFlow : OAuthActionFlow { override fun reset() = Unit } -private class FakeAccountProviderAccessControl : AccountProviderAccessControl { - override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = true -} - private class FakeNodeFactoriesBindings( private val nodeFactories: Map, AssistedNodeFactory<*>>, ) : NodeFactoriesBindings { diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt new file mode 100644 index 0000000000..9ebd3be7fc --- /dev/null +++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/accesscontrol/FakeAccountProviderAccessControl.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 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.features.login.test.accesscontrol + +import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAccountProviderAccessControl( + private val isAllowedToConnectToAccountProviderResult: (String) -> Boolean = { lambdaError() }, +) : AccountProviderAccessControl { + override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean { + return isAllowedToConnectToAccountProviderResult(accountProviderUrl) + } +} From be124610f047b97634f0d24a93e5439c6b873c66 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:28:22 +0200 Subject: [PATCH 08/13] Reuse the existing FakeOAuthActionFlow in RootFlowNodeTest RootFlowNodeTest was declaring its own no-op fake, while the oauth test module already provides one. Use it, and move it out of the customtab package, since it is not specific to the custom tabs. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/element/android/appnav/RootFlowNodeTest.kt | 10 +--------- .../features/login/impl/DefaultLoginEntryPointTest.kt | 2 +- .../ConfirmAccountProviderPresenterTest.kt | 2 +- .../impl/screens/onboarding/OnBoardingPresenterTest.kt | 2 +- .../oauth/test/{customtab => }/FakeOAuthActionFlow.kt | 2 +- 5 files changed, 5 insertions(+), 13 deletions(-) rename libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/{customtab => }/FakeOAuthActionFlow.kt (94%) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 7562356ffd..0bb8cb171b 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -51,9 +51,8 @@ import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder -import io.element.android.libraries.oauth.api.OAuthAction -import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore @@ -63,7 +62,6 @@ import io.element.android.services.apperror.test.FakeAppErrorStateService import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -210,12 +208,6 @@ private class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope): SyncOrchestrator = error("Not used") } -private class FakeOAuthActionFlow : OAuthActionFlow { - override fun post(oAuthAction: OAuthAction) = Unit - override suspend fun collect(collector: FlowCollector) = Unit - override fun reset() = Unit -} - private class FakeNodeFactoriesBindings( private val nodeFactories: Map, AssistedNodeFactory<*>>, ) : NodeFactoriesBindings { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt index 4e040a7556..8a0bf4c307 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt @@ -16,7 +16,7 @@ import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.classic.FakeElementClassicConnection import io.element.android.features.preferences.test.FakePreferencesEntryPoint -import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode import kotlinx.coroutines.test.runTest diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 229fc375a2..1d9c32c0a0 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthActionFlow -import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 2b64ae8b71..3c2e4b574f 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.test.A_LOGIN_HINT import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.oauth.api.OAuthActionFlow -import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor diff --git a/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt b/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt similarity index 94% rename from libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt rename to libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt index 5a5ca4369e..49abe58622 100644 --- a/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/customtab/FakeOAuthActionFlow.kt +++ b/libraries/oauth/test/src/main/kotlin/io/element/android/libraries/oauth/test/FakeOAuthActionFlow.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.oauth.test.customtab +package io.element.android.libraries.oauth.test import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthActionFlow From d71f691d752c9d9004466c2c1359562eb37d0c28 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:41:30 +0200 Subject: [PATCH 09/13] Move the session related classes to a session package MatrixSessionCache and SyncOrchestrator were in the di package, which does not describe what they do. Move them, along with their tests, to a session package, and extract FakeSyncOrchestratorFactory from RootFlowNodeTest so that other tests can use it. Co-Authored-By: Claude Opus 5 (1M context) --- .../io/element/android/appnav/RootFlowNode.kt | 2 +- .../appnav/root/RootNavStateFlowFactory.kt | 2 +- .../{di => session}/MatrixSessionCache.kt | 2 +- .../{di => session}/SyncOrchestrator.kt | 2 +- .../android/appnav/RootFlowNodeTest.kt | 12 +++--------- .../android/appnav/SyncOrchestratorTest.kt | 2 +- .../session/FakeSyncOrchestratorFactory.kt | 19 +++++++++++++++++++ .../{di => session}/MatrixSessionCacheTest.kt | 2 +- 8 files changed, 28 insertions(+), 15 deletions(-) rename appnav/src/main/kotlin/io/element/android/appnav/{di => session}/MatrixSessionCache.kt (99%) rename appnav/src/main/kotlin/io/element/android/appnav/{di => session}/SyncOrchestrator.kt (99%) create mode 100644 appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt rename appnav/src/test/kotlin/io/element/android/appnav/{di => session}/MatrixSessionCacheTest.kt (99%) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 19ec427303..c9deaeb3b3 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -32,7 +32,6 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.annotations.ContributesNode -import io.element.android.appnav.di.MatrixSessionCache import io.element.android.appnav.intent.IntentResolver import io.element.android.appnav.intent.ResolvedIntent import io.element.android.appnav.room.RoomFlowNode @@ -40,6 +39,7 @@ import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView +import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt index 9a91e9765b..8b02752a64 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -11,7 +11,7 @@ package io.element.android.appnav.root import com.bumble.appyx.core.state.MutableSavedStateMap import com.bumble.appyx.core.state.SavedStateMap import dev.zacsweers.metro.Inject -import io.element.android.appnav.di.MatrixSessionCache +import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.preferences.api.CacheService import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt similarity index 99% rename from appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt rename to appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt index a4bccfd6d7..ffae5f2b17 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.di +package io.element.android.appnav.session import androidx.annotation.VisibleForTesting import com.bumble.appyx.core.state.MutableSavedStateMap diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt similarity index 99% rename from appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt rename to appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt index 35a6a224ae..3a72c10ab7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/SyncOrchestrator.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.di +package io.element.android.appnav.session import androidx.annotation.VisibleForTesting import dev.zacsweers.metro.Assisted diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 0bb8cb171b..c487de1d25 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -25,11 +25,11 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl import com.google.common.truth.Truth.assertThat -import io.element.android.appnav.di.MatrixSessionCache -import io.element.android.appnav.di.SyncOrchestrator import io.element.android.appnav.intent.IntentResolver import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter +import io.element.android.appnav.session.FakeSyncOrchestratorFactory +import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.features.login.test.FakeLoginEntryPoint @@ -46,13 +46,12 @@ import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder -import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver import io.element.android.libraries.oauth.test.FakeOAuthActionFlow +import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore @@ -60,7 +59,6 @@ import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.apperror.test.FakeAppErrorStateService import io.element.android.tests.testutils.robolectric.RobolectricTest -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent @@ -204,10 +202,6 @@ private class NotUsedPresenter : Presenter { override fun present(): T = error("Not used") } -private class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { - override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope): SyncOrchestrator = error("Not used") -} - private class FakeNodeFactoriesBindings( private val nodeFactories: Map, AssistedNodeFactory<*>>, ) : NodeFactoriesBindings { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt index ab19ae0b54..32d0ec8f56 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt @@ -8,7 +8,7 @@ package io.element.android.appnav -import io.element.android.appnav.di.SyncOrchestrator +import io.element.android.appnav.session.SyncOrchestrator import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.matrix.api.sync.SyncState diff --git a/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt new file mode 100644 index 0000000000..586c8f0804 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/session/FakeSyncOrchestratorFactory.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 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.appnav.session + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.CoroutineScope + +class FakeSyncOrchestratorFactory : SyncOrchestrator.Factory { + override fun create( + matrixClient: MatrixClient, + sessionCoroutineScope: CoroutineScope, + ): SyncOrchestrator = lambdaError() +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt similarity index 99% rename from appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt rename to appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt index cba8d06f90..bf315ddb01 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.appnav.di +package io.element.android.appnav.session import com.bumble.appyx.core.state.MutableSavedStateMapImpl import com.google.common.truth.Truth.assertThat From e85c5041a476ba589eaa3bea0f947cccd5e39733 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:51:30 +0200 Subject: [PATCH 10/13] Move NotUsedPresenter to the testutils module The fake presenter was declared in RootFlowNodeTest, so other tests could not use it. Move it to the testutils module, in a new presenter package, along with MutablePresenter. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/RootFlowNodeTest.kt | 10 +--------- .../{ => presenter}/MutablePresenter.kt | 2 +- .../testutils/presenter/NotUsedPresenter.kt | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 10 deletions(-) rename tests/testutils/src/main/kotlin/io/element/android/tests/testutils/{ => presenter}/MutablePresenter.kt (93%) create mode 100644 tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index c487de1d25..acc57e8023 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -43,7 +43,6 @@ import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint import io.element.android.libraries.architecture.AssistedNodeFactory import io.element.android.libraries.architecture.NodeFactoriesBindings -import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.FakeSdkMetadata @@ -58,6 +57,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.apperror.test.FakeAppErrorStateService +import io.element.android.tests.testutils.presenter.NotUsedPresenter import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -194,14 +194,6 @@ private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( loginHint = A_LOGIN_PARAMS.loginHint, ) -/** - * The presenters are only used when the [Node] is rendered, which does not happen in these tests. - */ -private class NotUsedPresenter : Presenter { - @Composable - override fun present(): T = error("Not used") -} - private class FakeNodeFactoriesBindings( private val nodeFactories: Map, AssistedNodeFactory<*>>, ) : NodeFactoriesBindings { diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt similarity index 93% rename from tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt rename to tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt index ae8768e0b0..13fcc920c2 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/MutablePresenter.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/MutablePresenter.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.tests.testutils +package io.element.android.tests.testutils.presenter import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt new file mode 100644 index 0000000000..bd786c2484 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/presenter/NotUsedPresenter.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 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.tests.testutils.presenter + +import androidx.compose.runtime.Composable +import io.element.android.libraries.architecture.Presenter + +/** + * A presenter that cannot be used, util for testing Nodes. + */ +class NotUsedPresenter : Presenter { + @Composable + override fun present(): T = error("Not used") +} From 8e4b2145d5f55d76ef9e00a00cd7f259f46215db Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 15:56:39 +0200 Subject: [PATCH 11/13] Move FakeNodeFactoriesBindings to the testutils module The fake was declared in RootFlowNodeTest, so other tests could not use it. Move it to the testutils module, in a new node package. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/RootFlowNodeTest.kt | 9 +-------- .../node/FakeNodeFactoriesBindings.kt | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index acc57e8023..c39b468cac 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -42,7 +42,6 @@ import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint import io.element.android.libraries.architecture.AssistedNodeFactory -import io.element.android.libraries.architecture.NodeFactoriesBindings import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.FakeSdkMetadata @@ -57,6 +56,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.apperror.test.FakeAppErrorStateService +import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings import io.element.android.tests.testutils.presenter.NotUsedPresenter import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -65,7 +65,6 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -import kotlin.reflect.KClass class RootFlowNodeTest : RobolectricTest() { @get:Rule @@ -194,12 +193,6 @@ private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( loginHint = A_LOGIN_PARAMS.loginHint, ) -private class FakeNodeFactoriesBindings( - private val nodeFactories: Map, AssistedNodeFactory<*>>, -) : NodeFactoriesBindings { - override fun nodeFactories() = nodeFactories -} - /** * [RootFlowNode] creates its children using the dependency injection graph of its ancestors, so it needs a parent * owning a graph able to create a [NotLoggedInFlowNode]. diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt new file mode 100644 index 0000000000..cee2c74960 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeNodeFactoriesBindings.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 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.tests.testutils.node + +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.architecture.AssistedNodeFactory +import io.element.android.libraries.architecture.NodeFactoriesBindings +import kotlin.reflect.KClass + +class FakeNodeFactoriesBindings( + private val nodeFactories: Map, AssistedNodeFactory<*>>, +) : NodeFactoriesBindings { + override fun nodeFactories() = nodeFactories +} From a6a09a8be0204c90d639657e3155d95b16dfadf1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 16:01:31 +0200 Subject: [PATCH 12/13] Move FakeParentNode to the testutils module The fake was declared in RootFlowNodeTest, so other tests could not use it. Move it to the testutils module, in the node package, and make its documentation generic. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/RootFlowNodeTest.kt | 24 +------------ .../tests/testutils/node/FakeParentNode.kt | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 23 deletions(-) create mode 100644 tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index c39b468cac..0916c4f09a 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -12,13 +12,8 @@ package io.element.android.appnav import android.content.Intent import android.net.Uri import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.AncestryInfo import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.node.ParentNode import com.bumble.appyx.core.node.node import com.bumble.appyx.navmodel.backstack.activeElement import com.bumble.appyx.testing.junit4.util.MainDispatcherRule @@ -42,7 +37,6 @@ import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint import io.element.android.libraries.architecture.AssistedNodeFactory -import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService @@ -57,6 +51,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.apperror.test.FakeAppErrorStateService import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings +import io.element.android.tests.testutils.node.FakeParentNode import io.element.android.tests.testutils.presenter.NotUsedPresenter import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -192,20 +187,3 @@ private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( accountProvider = A_LOGIN_PARAMS.accountProvider, loginHint = A_LOGIN_PARAMS.loginHint, ) - -/** - * [RootFlowNode] creates its children using the dependency injection graph of its ancestors, so it needs a parent - * owning a graph able to create a [NotLoggedInFlowNode]. - */ -private class FakeParentNode( - override val graph: Any, -) : ParentNode( - navModel = PermanentNavModel(navTargets = setOf(Unit), savedStateMap = null), - buildContext = BuildContext.root(savedStateMap = null), -), - DependencyInjectionGraphOwner { - override fun resolve(navTarget: Unit, buildContext: BuildContext): Node = node(buildContext) {} - - @Composable - override fun View(modifier: Modifier) = Unit -} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt new file mode 100644 index 0000000000..1375043d48 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/FakeParentNode.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 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.tests.testutils.node + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.node.node +import io.element.android.libraries.di.DependencyInjectionGraphOwner + +/** + * A Node creates its children using the dependency injection graph of its ancestors, so a Node under test + * needs such a parent to be able to create its children. + */ +class FakeParentNode( + override val graph: Any, +) : ParentNode( + navModel = PermanentNavModel(navTargets = setOf(Unit), savedStateMap = null), + buildContext = BuildContext.root(savedStateMap = null), +), + DependencyInjectionGraphOwner { + override fun resolve(navTarget: Unit, buildContext: BuildContext): Node = node(buildContext) {} + + @Composable + override fun View(modifier: Modifier) = Unit +} From 1209d7e283af108172b14b1ddfec2d12c798904d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Aug 2026 16:02:35 +0200 Subject: [PATCH 13/13] Move the test constants to the top of RootFlowNodeTest Declaring them before the test class makes them easier to find. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/RootFlowNodeTest.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index 0916c4f09a..fea30c8019 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -61,6 +61,18 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +private const val A_LOGIN_LINK = "https://mobile.element.io/element/?account_provider=example.com&login_hint=mxid:@alice:example.com" + +private val A_LOGIN_PARAMS = LoginParams( + accountProvider = "example.com", + loginHint = "mxid:@alice:example.com", +) + +private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( + accountProvider = A_LOGIN_PARAMS.accountProvider, + loginHint = A_LOGIN_PARAMS.loginHint, +) + class RootFlowNodeTest : RobolectricTest() { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() @@ -175,15 +187,3 @@ class RootFlowNodeTest : RobolectricTest() { ) } } - -private const val A_LOGIN_LINK = "https://mobile.element.io/element/?account_provider=example.com&login_hint=mxid:@alice:example.com" - -private val A_LOGIN_PARAMS = LoginParams( - accountProvider = "example.com", - loginHint = "mxid:@alice:example.com", -) - -private val A_LOGIN_ENTRY_POINT_PARAMS = LoginEntryPoint.Params( - accountProvider = A_LOGIN_PARAMS.accountProvider, - loginHint = A_LOGIN_PARAMS.loginHint, -)