diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 054e9525c7..12b95d9a09 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -61,17 +61,23 @@ 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) 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) + testImplementation(projects.features.preferences.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/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index acf7b66db9..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 @@ -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/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 new file mode 100644 index 0000000000..fea30c8019 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -0,0 +1,189 @@ +/* + * 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 com.bumble.appyx.core.modality.AncestryInfo +import com.bumble.appyx.core.modality.BuildContext +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.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 +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 +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.featureflag.test.FakeFeatureFlagService +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.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 +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 +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +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() + + @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 { + 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()) + 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(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 { + 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() + 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(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, + 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 { true }, + 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 = { 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, + ) + } +} 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 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/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/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) + } +} 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 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) +} 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 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 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 +} 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 +} 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") +} 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}"