Merge pull request #7488 from element-hq/feature/bma/fixColdStartDeepLink

Fix cold start with a mobile deep link
This commit is contained in:
Benoit Marty
2026-08-20 17:24:30 +02:00
committed by GitHub
30 changed files with 494 additions and 12 deletions
+6
View File
@@ -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)
@@ -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 {
@@ -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
@@ -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
@@ -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
@@ -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,
)
}
}
@@ -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
@@ -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()
}
@@ -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
@@ -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
@@ -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
@@ -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
+1
View File
@@ -15,6 +15,7 @@ android {
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.features.login.api)
implementation(projects.tests.testutils)
}
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
@@ -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<SessionId> = emptyFlow(),
) : CacheService
+1
View File
@@ -14,6 +14,7 @@ android {
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.features.rageshake.api)
implementation(libs.coroutines.core)
implementation(projects.tests.testutils)
@@ -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)
}
+19
View File
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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
@@ -104,6 +104,7 @@ class KonsistClassNameTest {
"FakeImageLoader",
"FakeKeyStore",
"FakeListenableFuture",
"FakeParentNode",
)
.assertTrue {
val interfaceName = it.name
@@ -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<KClass<out Node>, AssistedNodeFactory<*>>,
) : NodeFactoriesBindings {
override fun nodeFactories() = nodeFactories
}
@@ -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<Unit>(
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
}
@@ -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
@@ -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<T> : Presenter<T> {
@Composable
override fun present(): T = error("Not used")
}
+12
View File
@@ -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}"