Merge branch 'develop' into feature/fga/join_space

This commit is contained in:
ganfra 2025-09-19 16:35:55 +02:00
commit 5cda6730c4
446 changed files with 5669 additions and 2617 deletions

1
.gitignore vendored
View file

@ -62,6 +62,7 @@ captures/
# Android Studio 3 in .gitignore file. # Android Studio 3 in .gitignore file.
.idea/caches .idea/caches
.idea/copilot .idea/copilot
.idea/copilot.*
.idea/inspectionProfiles .idea/inspectionProfiles
# Shelved changes in the IDE # Shelved changes in the IDE
.idea/shelf .idea/shelf

View file

@ -24,6 +24,7 @@ import extension.koverDependencies
import extension.locales import extension.locales
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.setupKover import extension.setupKover
import extension.testCommonDependencies
import java.util.Locale import java.util.Locale
plugins { plugins {
@ -290,15 +291,9 @@ dependencies {
implementation(libs.matrix.emojibase.bindings) implementation(libs.matrix.emojibase.bindings)
testImplementation(libs.test.junit) testCommonDependencies(libs)
testImplementation(libs.test.robolectric)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
koverDependencies() koverDependencies()
} }

View file

@ -9,6 +9,7 @@
import extension.allFeaturesApi import extension.allFeaturesApi
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
@ -42,18 +43,12 @@ dependencies {
implementation(projects.features.ftue.api) implementation(projects.features.ftue.api)
implementation(projects.features.share.api) implementation(projects.features.share.api)
implementation(projects.features.viewfolder.api)
implementation(projects.services.apperror.impl) implementation(projects.services.apperror.impl)
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
testImplementation(libs.test.junit) testCommonDependencies(libs)
testImplementation(libs.test.robolectric)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.login.test) testImplementation(projects.features.login.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.test) testImplementation(projects.libraries.oidc.test)
@ -61,11 +56,8 @@ dependencies {
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test) testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)
testImplementation(libs.test.appyx.junit)
testImplementation(libs.test.arch.core)
} }

View file

@ -52,7 +52,6 @@ import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint
@ -119,7 +118,6 @@ class LoggedInFlowNode(
private val shareEntryPoint: ShareEntryPoint, private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues, private val sendingQueue: SendQueues,
private val logoutEntryPoint: LogoutEntryPoint,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration, private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val sessionEnterpriseService: SessionEnterpriseService, private val sessionEnterpriseService: SessionEnterpriseService,
@ -277,9 +275,6 @@ class LoggedInFlowNode(
@Parcelize @Parcelize
data class IncomingShare(val intent: Intent) : NavTarget data class IncomingShare(val intent: Intent) : NavTarget
@Parcelize
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
@Parcelize @Parcelize
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
} }
@ -324,10 +319,6 @@ class LoggedInFlowNode(
override fun onReportBugClick() { override fun onReportBugClick() {
plugins<Callback>().forEach { it.onOpenBugReport() } plugins<Callback>().forEach { it.onOpenBugReport() }
} }
override fun onLogoutForNativeSlidingSyncMigrationNeeded() {
backstack.push(NavTarget.LogoutForNativeSlidingSyncMigrationNeeded)
}
} }
homeEntryPoint homeEntryPoint
.nodeBuilder(this, buildContext) .nodeBuilder(this, buildContext)
@ -454,8 +445,7 @@ class LoggedInFlowNode(
.build() .build()
} }
NavTarget.Ftue -> { NavTarget.Ftue -> {
ftueEntryPoint.nodeBuilder(this, buildContext) ftueEntryPoint.createNode(this, buildContext)
.build()
} }
NavTarget.RoomDirectorySearch -> { NavTarget.RoomDirectorySearch -> {
roomDirectoryEntryPoint.nodeBuilder(this, buildContext) roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
@ -486,17 +476,6 @@ class LoggedInFlowNode(
.params(ShareEntryPoint.Params(intent = navTarget.intent)) .params(ShareEntryPoint.Params(intent = navTarget.intent))
.build() .build()
} }
is NavTarget.LogoutForNativeSlidingSyncMigrationNeeded -> {
val callback = object : LogoutEntryPoint.Callback {
override fun onChangeRecoveryKeyClick() {
backstack.push(NavTarget.SecureBackup())
}
}
logoutEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
is NavTarget.IncomingVerificationRequest -> { is NavTarget.IncomingVerificationRequest -> {
incomingVerificationEntryPoint.nodeBuilder(this, buildContext) incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(IncomingVerificationEntryPoint.Params(navTarget.data)) .params(IncomingVerificationEntryPoint.Params(navTarget.data))

View file

@ -39,7 +39,6 @@ import io.element.android.features.login.api.accesscontrol.AccountProviderAccess
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -47,13 +46,13 @@ import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.oidc.api.OidcAction import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -65,13 +64,12 @@ import timber.log.Timber
class RootFlowNode( class RootFlowNode(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService, private val sessionStore: SessionStore,
private val accountProviderAccessControl: AccountProviderAccessControl, private val accountProviderAccessControl: AccountProviderAccessControl,
private val navStateFlowFactory: RootNavStateFlowFactory, private val navStateFlowFactory: RootNavStateFlowFactory,
private val matrixSessionCache: MatrixSessionCache, private val matrixSessionCache: MatrixSessionCache,
private val presenter: RootPresenter, private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint, private val bugReportEntryPoint: BugReportEntryPoint,
private val viewFolderEntryPoint: ViewFolderEntryPoint,
private val signedOutEntryPoint: SignedOutEntryPoint, private val signedOutEntryPoint: SignedOutEntryPoint,
private val intentResolver: IntentResolver, private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,
@ -154,7 +152,7 @@ class RootFlowNode(
onSuccess: (SessionId) -> Unit, onSuccess: (SessionId) -> Unit,
onFailure: () -> Unit onFailure: () -> Unit
) { ) {
val latestSessionId = authenticationService.getLatestSessionId() val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) { if (latestSessionId == null) {
onFailure() onFailure()
return return
@ -200,11 +198,6 @@ class RootFlowNode(
@Parcelize @Parcelize
data object BugReport : NavTarget data object BugReport : NavTarget
@Parcelize
data class ViewLogs(
val rootPath: String,
) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -244,31 +237,12 @@ class RootFlowNode(
NavTarget.SplashScreen -> splashNode(buildContext) NavTarget.SplashScreen -> splashNode(buildContext)
NavTarget.BugReport -> { NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback { val callback = object : BugReportEntryPoint.Callback {
override fun onBugReportSent() {
backstack.pop()
}
override fun onViewLogs(basePath: String) {
backstack.push(NavTarget.ViewLogs(rootPath = basePath))
}
}
bugReportEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
is NavTarget.ViewLogs -> {
val callback = object : ViewFolderEntryPoint.Callback {
override fun onDone() { override fun onDone() {
backstack.pop() backstack.pop()
} }
} }
val params = ViewFolderEntryPoint.Params( bugReportEntryPoint
rootPath = navTarget.rootPath,
)
viewFolderEntryPoint
.nodeBuilder(this, buildContext) .nodeBuilder(this, buildContext)
.params(params)
.callback(callback) .callback(callback)
.build() .build()
} }
@ -294,7 +268,7 @@ class RootFlowNode(
private suspend fun onLoginLink(params: LoginParams) { private suspend fun onLoginLink(params: LoginParams) {
// Is there a session already? // Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId() val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) { if (latestSessionId == null) {
// No session, open login // No session, open login
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) { if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
@ -311,7 +285,7 @@ class RootFlowNode(
private suspend fun onIncomingShare(intent: Intent) { private suspend fun onIncomingShare(intent: Intent) {
// Is there a session already? // Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId() val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) { if (latestSessionId == null) {
// No session, open login // No session, open login
switchToNotLoggedInFlow(null) switchToNotLoggedInFlow(null)
@ -368,3 +342,5 @@ class RootFlowNode(
.attachSession() .attachSession()
} }
} }
private suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId)

View file

@ -9,6 +9,6 @@ package io.element.android.appnav.di
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
interface RoomComponentFactory { fun interface RoomComponentFactory {
fun create(room: JoinedRoom): Any fun create(room: JoinedRoom): Any
} }

View file

@ -12,9 +12,9 @@ import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appnav.di.MatrixSessionCache import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -28,7 +28,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFact
*/ */
@Inject @Inject
class RootNavStateFlowFactory( class RootNavStateFlowFactory(
private val authenticationService: MatrixAuthenticationService, private val sessionStore: SessionStore,
private val cacheService: CacheService, private val cacheService: CacheService,
private val matrixSessionCache: MatrixSessionCache, private val matrixSessionCache: MatrixSessionCache,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
@ -39,7 +39,7 @@ class RootNavStateFlowFactory(
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> { fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
return combine( return combine(
cacheIndexFlow(savedStateMap), cacheIndexFlow(savedStateMap),
authenticationService.loggedInStateFlow(), sessionStore.loggedInStateFlow(),
) { cacheIndex, loggedInState -> ) { cacheIndex, loggedInState ->
RootNavState( RootNavState(
cacheIndex = cacheIndex, cacheIndex = cacheIndex,

View file

@ -2,5 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Sair &amp; Atualizar"</string> <string name="banner_migrate_to_native_sliding_sync_action">"Sair &amp; Atualizar"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s já não suporta o protocolo antigo. Termina a sessão e volta a iniciar sessão para continuares a utilizar a aplicação."</string> <string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s já não suporta o protocolo antigo. Termina a sessão e volta a iniciar sessão para continuares a utilizar a aplicação."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Seu homeserver não suporta mais o protocolo antigo. Termine sessão e volte a iniciar sessão para continuar a utilizar a aplicação."</string> <string name="banner_migrate_to_native_sliding_sync_force_logout_title">"O teu servidor já não permite o protocolo antigo. Termine sessão e volte a iniciá-la para continuar a utilizar a aplicação."</string>
</resources> </resources>

View file

@ -501,22 +501,16 @@ class LoggedInPresenterTest {
@Test @Test
fun `present - CheckSlidingSyncProxyAvailability forces the sliding sync migration under the right circumstances`() = runTest { fun `present - CheckSlidingSyncProxyAvailability forces the sliding sync migration under the right circumstances`() = runTest {
// The migration will be forced if: // The migration will be forced if the user is not using the native sliding sync
// - The user is not using the native sliding sync
// - The sliding sync proxy is no longer supported
// - The native sliding sync is supported
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) }, currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
availableSlidingSyncVersionsLambda = { Result.success(listOf(SlidingSyncVersion.Native)) },
) )
createLoggedInPresenter( createLoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse() assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability) initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue() assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue()
} }
} }

View file

@ -9,4 +9,4 @@ package io.element.android.features.analytics.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
interface AnalyticsEntryPoint : SimpleFeatureEntryPoint fun interface AnalyticsEntryPoint : SimpleFeatureEntryPoint

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2023, 2024 New Vector Ltd. * Copyright 2023, 2024 New Vector Ltd.
@ -30,13 +31,7 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
testImplementation(libs.test.junit) testCommonDependencies(libs)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
} }

View file

@ -0,0 +1,39 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.analytics.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultAnalyticsEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node creation`() {
val entryPoint = DefaultAnalyticsEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
AnalyticsOptInNode(
buildContext = buildContext,
plugins = plugins,
AnalyticsOptInPresenter(
buildMeta = aBuildMeta(),
analyticsService = FakeAnalyticsService()
)
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(AnalyticsOptInNode::class.java)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2023, 2024 New Vector Ltd. * Copyright 2023, 2024 New Vector Ltd.
@ -22,8 +23,5 @@ dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
testImplementation(libs.test.junit) testCommonDependencies(libs)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testImplementation(projects.tests.testutils)
} }

View file

@ -29,6 +29,7 @@ interface ElementCallEntryPoint {
* @param senderName The name of the sender of the event that started the call. * @param senderName The name of the sender of the event that started the call.
* @param avatarUrl The avatar url of the room or DM. * @param avatarUrl The avatar url of the room or DM.
* @param timestamp The timestamp of the event that started the call. * @param timestamp The timestamp of the event that started the call.
* @param expirationTimestamp The timestamp at which the call should stop ringing.
* @param notificationChannelId The id of the notification channel to use for the call notification. * @param notificationChannelId The id of the notification channel to use for the call notification.
* @param textContent The text content of the notification. If null the default content from the system will be used. * @param textContent The text content of the notification. If null the default content from the system will be used.
*/ */
@ -40,6 +41,7 @@ interface ElementCallEntryPoint {
senderName: String?, senderName: String?,
avatarUrl: String?, avatarUrl: String?,
timestamp: Long, timestamp: Long,
expirationTimestamp: Long,
notificationChannelId: String, notificationChannelId: String,
textContent: String?, textContent: String?,
) )

View file

@ -1,6 +1,7 @@
import extension.buildConfigFieldStr import extension.buildConfigFieldStr
import extension.readLocalProperty import extension.readLocalProperty
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2024 New Vector Ltd.
@ -87,12 +88,7 @@ dependencies {
implementation(libs.element.call.embedded) implementation(libs.element.call.embedded)
api(projects.features.call.api) api(projects.features.call.api)
testImplementation(libs.coroutines.test) testCommonDependencies(libs, true)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.mockk)
testImplementation(projects.features.call.test) testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
@ -100,7 +96,5 @@ dependencies {
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.tests.testutils) testImplementation(projects.services.toolbox.test)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -43,6 +43,7 @@ class DefaultElementCallEntryPoint(
senderName: String?, senderName: String?,
avatarUrl: String?, avatarUrl: String?,
timestamp: Long, timestamp: Long,
expirationTimestamp: Long,
notificationChannelId: String, notificationChannelId: String,
textContent: String?, textContent: String?,
) { ) {
@ -55,6 +56,7 @@ class DefaultElementCallEntryPoint(
senderName = senderName, senderName = senderName,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
timestamp = timestamp, timestamp = timestamp,
expirationTimestamp = expirationTimestamp,
notificationChannelId = notificationChannelId, notificationChannelId = notificationChannelId,
textContent = textContent, textContent = textContent,
) )

View file

@ -26,4 +26,6 @@ data class CallNotificationData(
val notificationChannelId: String, val notificationChannelId: String,
val timestamp: Long, val timestamp: Long,
val textContent: String?, val textContent: String?,
// Expiration timestamp in millis since epoch
val expirationTimestamp: Long,
) : Parcelable ) : Parcelable

View file

@ -64,6 +64,7 @@ class RingingCallNotificationCreator(
roomAvatarUrl: String?, roomAvatarUrl: String?,
notificationChannelId: String, notificationChannelId: String,
timestamp: Long, timestamp: Long,
expirationTimestamp: Long,
textContent: String?, textContent: String?,
): Notification? { ): Notification? {
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
@ -88,6 +89,7 @@ class RingingCallNotificationCreator(
notificationChannelId = notificationChannelId, notificationChannelId = notificationChannelId,
timestamp = timestamp, timestamp = timestamp,
textContent = textContent, textContent = textContent,
expirationTimestamp = expirationTimestamp,
) )
val declineIntent = PendingIntentCompat.getBroadcast( val declineIntent = PendingIntentCompat.getBroadcast(

View file

@ -176,6 +176,7 @@ internal fun IncomingCallScreenPreview() = ElementPreview {
notificationChannelId = "incoming_call", notificationChannelId = "incoming_call",
timestamp = 0L, timestamp = 0L,
textContent = null, textContent = null,
expirationTimestamp = 1000L,
), ),
onAnswer = {}, onAnswer = {},
onCancel = {}, onCancel = {},

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -53,7 +54,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds import kotlin.math.min
/** /**
* Manages the active call state. * Manages the active call state.
@ -98,6 +99,7 @@ class DefaultActiveCallManager(
private val defaultCurrentCallService: DefaultCurrentCallService, private val defaultCurrentCallService: DefaultCurrentCallService,
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val systemClock: SystemClock,
) : ActiveCallManager { ) : ActiveCallManager {
private val tag = "DefaultActiveCallManager" private val tag = "DefaultActiveCallManager"
private var timedOutCallJob: Job? = null private var timedOutCallJob: Job? = null
@ -118,8 +120,20 @@ class DefaultActiveCallManager(
override suspend fun registerIncomingCall(notificationData: CallNotificationData) { override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
mutex.withLock { mutex.withLock {
val ringDuration =
min(
notificationData.expirationTimestamp - systemClock.epochMillis(),
ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L
)
if (ringDuration < 0) {
// Should already have stopped ringing, ignore.
Timber.tag(tag).d("Received timed-out incoming ringing call for room id: ${notificationData.roomId}, cancel ringing")
return
}
appForegroundStateService.updateHasRingingCall(true) appForegroundStateService.updateHasRingingCall(true)
Timber.tag(tag).d("Received incoming call for room id: ${notificationData.roomId}") Timber.tag(tag).d("Received incoming call for room id: ${notificationData.roomId}, ringDuration(ms): $ringDuration")
if (activeCall.value != null) { if (activeCall.value != null) {
displayMissedCallNotification(notificationData) displayMissedCallNotification(notificationData)
Timber.tag(tag).w("Already have an active call, ignoring incoming call: $notificationData") Timber.tag(tag).w("Already have an active call, ignoring incoming call: $notificationData")
@ -138,14 +152,14 @@ class DefaultActiveCallManager(
showIncomingCallNotification(notificationData) showIncomingCallNotification(notificationData)
// Wait for the ringing call to time out // Wait for the ringing call to time out
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds) delay(timeMillis = ringDuration)
incomingCallTimedOut(displayMissedCallNotification = true) incomingCallTimedOut(displayMissedCallNotification = true)
} }
// Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data // Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data
if (activeWakeLock?.isHeld == false) { if (activeWakeLock?.isHeld == false) {
Timber.tag(tag).d("Acquiring partial wakelock") Timber.tag(tag).d("Acquiring partial wakelock")
activeWakeLock.acquire(ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L) activeWakeLock.acquire(ringDuration)
} }
} }
} }
@ -180,12 +194,22 @@ class DefaultActiveCallManager(
} }
override suspend fun hungUpCall(callType: CallType) = mutex.withLock { override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
if (activeCall.value?.callType != callType) { Timber.tag(tag).d("Hung up call: $callType")
val currentActiveCall = activeCall.value ?: run {
Timber.tag(tag).w("No active call, ignoring hang up")
return
}
if (currentActiveCall.callType != callType) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring") Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
return return
} }
if (currentActiveCall.callState is CallState.Ringing) {
Timber.tag(tag).d("Hung up call: $callType") // Decline the call
val notificationData = currentActiveCall.callState.notificationData
matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull()
?.getRoom(notificationData.roomId)
?.declineCall(notificationData.eventId)
}
cancelIncomingCallNotification() cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) { if (activeWakeLock?.isHeld == true) {
@ -226,6 +250,7 @@ class DefaultActiveCallManager(
notificationChannelId = notificationData.notificationChannelId, notificationChannelId = notificationData.notificationChannelId,
timestamp = notificationData.timestamp, timestamp = notificationData.timestamp,
textContent = notificationData.textContent, textContent = notificationData.textContent,
expirationTimestamp = notificationData.expirationTimestamp,
) ?: return ) ?: return
runCatchingExceptions { runCatchingExceptions {
notificationManagerCompat.notify( notificationManagerCompat.notify(
@ -256,6 +281,43 @@ class DefaultActiveCallManager(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private fun observeRingingCall() { private fun observeRingingCall() {
activeCall
.filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
.flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall
val ringingInfo = activeCall.callState as CallState.Ringing
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
val room = client.getRoom(callType.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
Timber.tag(tag).d("Found room for ringing call: ${room.roomId}")
// If we have declined from another phone we want to stop ringing.
room.subscribeToCallDecline(ringingInfo.notificationData.eventId)
.filter { decliner ->
Timber.tag(tag).d("Call: $activeCall was declined by $decliner")
// only want to listen if the call was declined from another of my sessions,
// (we are ringing for an incoming call in a DM)
decliner == client.sessionId
}
}
.onEach { decliner ->
Timber.tag(tag).d("Call: $activeCall was declined by user from another session")
// Remove the active call and cancel the notification
activeCall.value = null
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after call declined from another session")
activeWakeLock.release()
}
cancelIncomingCallNotification()
}
.launchIn(coroutineScope)
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user // This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
// has joined the call from another session. // has joined the call from another session.
activeCall activeCall

View file

@ -45,8 +45,14 @@ class DefaultCallWidgetProvider(
val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow() val roomInfo = room.info()
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted, direct = room.isDm()) val isEncrypted = roomInfo.isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
val widgetSettings = callWidgetSettingsProvider.provide(
baseUrl = baseUrl,
encrypted = isEncrypted,
direct = room.isDm(),
hasActiveCall = roomInfo.hasRoomCall,
)
val callUrl = room.generateWidgetWebViewUrl( val callUrl = room.generateWidgetWebViewUrl(
widgetSettings = widgetSettings, widgetSettings = widgetSettings,
clientId = clientId, clientId = clientId,

View file

@ -59,6 +59,7 @@ class DefaultElementCallEntryPointTest {
senderName = "senderName", senderName = "senderName",
avatarUrl = "avatarUrl", avatarUrl = "avatarUrl",
timestamp = 0, timestamp = 0,
expirationTimestamp = 0,
notificationChannelId = "notificationChannelId", notificationChannelId = "notificationChannelId",
textContent = "textContent", textContent = "textContent",
) )

View file

@ -73,6 +73,7 @@ class RingingCallNotificationCreatorTest {
roomAvatarUrl = "https://example.com/avatar.jpg", roomAvatarUrl = "https://example.com/avatar.jpg",
notificationChannelId = "channelId", notificationChannelId = "channelId",
timestamp = 0L, timestamp = 0L,
expirationTimestamp = 20L,
textContent = "textContent", textContent = "textContent",
) )

View file

@ -22,13 +22,16 @@ import io.element.android.features.call.test.aCallNotificationData
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.NotificationIdProvider
@ -36,8 +39,12 @@ import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolde
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.plantTestTimber
import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -164,6 +171,102 @@ class DefaultActiveCallManagerTest {
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
} }
@Test
fun `Decline event - Hangup on a ringing call should send a decline event`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val room = mockk<JoinedRoom>(relaxed = true)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
val manager = createActiveCallManager(
matrixClientProvider = clientProvider,
notificationManagerCompat = notificationManagerCompat
)
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
coVerify {
room.declineCall(notificationEventId = notificationData.eventId)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `Decline event - Declining from another session should stop ringing`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val room = FakeJoinedRoom()
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
val manager = createActiveCallManager(
matrixClientProvider = clientProvider,
notificationManagerCompat = notificationManagerCompat
)
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
runCurrent()
// Simulate declined from other session
room.baseRoom.givenDecliner(matrixClient.sessionId, notificationData.eventId)
runCurrent()
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify { notificationManagerCompat.cancel(notificationId) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `Decline event - Should ignore decline for other notification events`() = runTest {
plantTestTimber()
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val room = FakeJoinedRoom()
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
val manager = createActiveCallManager(
matrixClientProvider = clientProvider,
notificationManagerCompat = notificationManagerCompat
)
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
runCurrent()
// Simulate declined for another notification event
room.baseRoom.givenDecliner(matrixClient.sessionId, AN_EVENT_ID_2)
runCurrent()
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
}
@Test @Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest { fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
setupShadowPowerManager() setupShadowPowerManager()
@ -267,6 +370,83 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `IncomingCall - rings no longer than expiration time`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val clock = FakeSystemClock()
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat, systemClock = clock)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
val eventTimestamp = A_FAKE_TIMESTAMP
// The call should not ring more than 30 seconds after the initial event was sent
val expirationTimestamp = eventTimestamp + 30_000
val callNotificationData = aCallNotificationData(
timestamp = eventTimestamp,
expirationTimestamp = expirationTimestamp,
)
// suppose it took 10s to be notified
clock.epochMillisResult = eventTimestamp + 10_000
manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
),
callState = CallState.Ringing(callNotificationData)
)
)
runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) }
// advance by 21s it should have stopped ringing
advanceTimeBy(21_000)
runCurrent()
verify { notificationManagerCompat.cancel(any()) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `IncomingCall - ignore expired ring lifetime`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val clock = FakeSystemClock()
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat, systemClock = clock)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
val eventTimestamp = A_FAKE_TIMESTAMP
// The call should not ring more than 30 seconds after the initial event was sent
val expirationTimestamp = eventTimestamp + 30_000
val callNotificationData = aCallNotificationData(
timestamp = eventTimestamp,
expirationTimestamp = expirationTimestamp,
)
// suppose it took 35s to be notified
clock.epochMillisResult = eventTimestamp + 35_000
manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isNull()
runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify(exactly = 0) { notificationManagerCompat.notify(notificationId, any()) }
}
private fun setupShadowPowerManager() { private fun setupShadowPowerManager() {
shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService<PowerManager>()).apply { shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService<PowerManager>()).apply {
setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true) setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
@ -277,6 +457,7 @@ class DefaultActiveCallManagerTest {
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(), onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true), notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
systemClock: FakeSystemClock = FakeSystemClock(),
) = DefaultActiveCallManager( ) = DefaultActiveCallManager(
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
@ -292,5 +473,6 @@ class DefaultActiveCallManagerTest {
defaultCurrentCallService = DefaultCurrentCallService(), defaultCurrentCallService = DefaultCurrentCallService(),
appForegroundStateService = FakeAppForegroundStateService(), appForegroundStateService = FakeAppForegroundStateService(),
imageLoaderHolder = FakeImageLoaderHolder(), imageLoaderHolder = FakeImageLoaderHolder(),
systemClock = systemClock,
) )
} }

View file

@ -30,6 +30,7 @@ fun aCallNotificationData(
avatarUrl: String? = AN_AVATAR_URL, avatarUrl: String? = AN_AVATAR_URL,
notificationChannelId: String = "channel_id", notificationChannelId: String = "channel_id",
timestamp: Long = 0L, timestamp: Long = 0L,
expirationTimestamp: Long = 30_000L,
textContent: String? = null, textContent: String? = null,
): CallNotificationData = CallNotificationData( ): CallNotificationData = CallNotificationData(
sessionId = sessionId, sessionId = sessionId,
@ -41,5 +42,6 @@ fun aCallNotificationData(
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
notificationChannelId = notificationChannelId, notificationChannelId = notificationChannelId,
timestamp = timestamp, timestamp = timestamp,
expirationTimestamp = expirationTimestamp,
textContent = textContent, textContent = textContent,
) )

View file

@ -38,6 +38,7 @@ class FakeElementCallEntryPoint(
senderName: String?, senderName: String?,
avatarUrl: String?, avatarUrl: String?,
timestamp: Long, timestamp: Long,
expirationTimestamp: Long,
notificationChannelId: String, notificationChannelId: String,
textContent: String?, textContent: String?,
) { ) {

View file

@ -14,7 +14,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint { fun interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
fun builder(parentNode: Node, buildContext: BuildContext): Builder fun builder(parentNode: Node, buildContext: BuildContext): Builder
interface Builder { interface Builder {

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2025 New Vector Ltd. * Copyright 2025 New Vector Ltd.
@ -37,15 +38,7 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
testCommonDependencies(libs, true)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -37,16 +37,7 @@ class ChangeRolesNode(
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.listType.toRoomMemberRole())
private val presenter = presenterFactory.run {
val role = when (inputs.listType) {
ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin
ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator
ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false)
}
create(role)
}
private val stateFlow = launchMolecule { presenter.present() } private val stateFlow = launchMolecule { presenter.present() }
suspend fun waitForRoleChanged() { suspend fun waitForRoleChanged() {
@ -63,3 +54,9 @@ class ChangeRolesNode(
) )
} }
} }
internal fun ChangeRoomMemberRolesListType.toRoomMemberRole() = when (this) {
ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin
ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator
ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false)
}

View file

@ -55,7 +55,7 @@ class ChangeRolesPresenter(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Presenter<ChangeRolesState> { ) : Presenter<ChangeRolesState> {
@AssistedFactory @AssistedFactory
interface Factory { fun interface Factory {
fun create(role: RoomMember.Role): ChangeRolesPresenter fun create(role: RoomMember.Role): ChangeRolesPresenter
} }

View file

@ -39,16 +39,13 @@ class ChangeRoomMemberRolesRootNode(
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>( ) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
navModel = PermanentNavModel( navModel = PermanentNavModel(
navTargets = setOf(NavTarget.Root), navTargets = setOf(NavTarget),
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
), DependencyInjectionGraphOwner, ChangeRoomMemberRolesEntryPoint.NodeProxy { ), DependencyInjectionGraphOwner, ChangeRoomMemberRolesEntryPoint.NodeProxy {
sealed interface NavTarget : Parcelable { @Parcelize object NavTarget : Parcelable
@Parcelize
object Root : NavTarget
}
data class Inputs( data class Inputs(
val joinedRoom: JoinedRoom, val joinedRoom: JoinedRoom,
@ -60,14 +57,10 @@ class ChangeRoomMemberRolesRootNode(
override val graph = roomComponentFactory.create(inputs.joinedRoom) override val graph = roomComponentFactory.create(inputs.joinedRoom)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return createNode<ChangeRolesNode>(
NavTarget.Root -> { buildContext = buildContext,
createNode<ChangeRolesNode>( plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
buildContext = buildContext, )
plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
)
}
}
} }
@Composable @Composable

View file

@ -0,0 +1,25 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.changeroommemberroles.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.matrix.api.room.RoomMember
import org.junit.Test
class ChangeRolesNodeTest {
@Test
fun `test toRoomMemberRole`() {
assertThat(ChangeRoomMemberRolesListType.Admins.toRoomMemberRole())
.isEqualTo(RoomMember.Role.Admin)
assertThat(ChangeRoomMemberRolesListType.Moderators.toRoomMemberRole())
.isEqualTo(RoomMember.Role.Moderator)
assertThat(ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving.toRoomMemberRole())
.isEqualTo(RoomMember.Role.Owner(false))
}
}

View file

@ -37,7 +37,6 @@ import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.collections.plus
class ChangeRolesPresenterTest { class ChangeRolesPresenterTest {
@Test @Test
@ -556,18 +555,18 @@ class ChangeRolesPresenterTest {
users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toPersistentMap() users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toPersistentMap()
) )
} }
}
private fun TestScope.createChangeRolesPresenter(
role: RoomMember.Role = RoomMember.Role.Admin, internal fun TestScope.createChangeRolesPresenter(
room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})), role: RoomMember.Role = RoomMember.Role.Admin,
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
): ChangeRolesPresenter { analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
return ChangeRolesPresenter( ): ChangeRolesPresenter {
role = role, return ChangeRolesPresenter(
room = room, role = role,
dispatchers = dispatchers, room = room,
analyticsService = analyticsService, dispatchers = dispatchers,
) analyticsService = analyticsService,
} )
} }

View file

@ -0,0 +1,44 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.changeroommemberroles.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultChangeRoomMemberRolesEntyPointTest {
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultChangeRoomMemberRolesEntyPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
ChangeRoomMemberRolesRootNode(
buildContext = buildContext,
plugins = plugins,
roomComponentFactory = { },
)
}
val room = FakeJoinedRoom()
val listType = ChangeRoomMemberRolesListType.Admins
val result = entryPoint.builder(parentNode, BuildContext.root(null))
.room(FakeJoinedRoom())
.listType(listType)
.build()
assertThat(result).isInstanceOf(ChangeRoomMemberRolesRootNode::class.java)
// Search for the Inputs plugin
val input = result.plugins.filterIsInstance<ChangeRoomMemberRolesRootNode.Inputs>().single()
assertThat(input.joinedRoom.roomId).isEqualTo(room.roomId)
assertThat(input.listType).isEqualTo(listType)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2022-2024 New Vector Ltd. * Copyright 2022-2024 New Vector Ltd.
@ -43,13 +44,7 @@ dependencies {
implementation(projects.features.invitepeople.api) implementation(projects.features.invitepeople.api)
api(projects.features.createroom.api) api(projects.features.createroom.api)
testImplementation(libs.test.junit) testCommonDependencies(libs, true)
testImplementation(libs.test.mockk)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
@ -58,7 +53,4 @@ dependencies {
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.startchat.test) testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -0,0 +1,46 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultCreateRoomEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultCreateRoomEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
CreateRoomFlowNode(
buildContext = buildContext,
plugins = plugins,
)
}
val callback = object : CreateRoomEntryPoint.Callback {
override fun onRoomCreated(roomId: RoomId) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
assertThat(result.plugins).contains(callback)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2024 New Vector Ltd.
@ -34,14 +35,6 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
api(projects.features.deactivation.api) api(projects.features.deactivation.api)
testImplementation(libs.test.junit) testCommonDependencies(libs, true)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
} }

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Confirme que pretende desativar a sua conta. Esta ação não pode ser desfeita."</string> <string name="screen_deactivate_account_confirmation_dialog_content">"Confirma que pretendes desativar a tua conta. Esta ação não pode ser desfeita."</string>
<string name="screen_deactivate_account_delete_all_messages">"Eliminar todas as minhas mensagens"</string> <string name="screen_deactivate_account_delete_all_messages">"Eliminar todas as minhas mensagens"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Aviso: futuros usuários podem ver conversas incompletas."</string> <string name="screen_deactivate_account_delete_all_messages_notice">"Aviso: futuros usuários podem ver conversas incompletas."</string>
<string name="screen_deactivate_account_description">"A desativação da sua conta é %1$s, irá:"</string> <string name="screen_deactivate_account_description">"A desativação da sua conta é %1$s, irá:"</string>

View file

@ -148,10 +148,10 @@ class AccountDeactivationPresenterTest {
assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized) assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized)
} }
} }
private fun createPresenter(
matrixClient: MatrixClient = FakeMatrixClient(),
) = AccountDeactivationPresenter(
matrixClient = matrixClient,
)
} }
internal fun createPresenter(
matrixClient: MatrixClient = FakeMatrixClient(),
) = AccountDeactivationPresenter(
matrixClient = matrixClient,
)

View file

@ -0,0 +1,34 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.logout.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultAccountDeactivationEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultAccountDeactivationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
AccountDeactivationNode(
buildContext = buildContext,
plugins = plugins,
presenter = createPresenter(),
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(AccountDeactivationNode::class.java)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2024 New Vector Ltd.
@ -22,8 +23,6 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
testImplementation(libs.coroutines.test) testCommonDependencies(libs)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
} }

View file

@ -7,14 +7,6 @@
package io.element.android.features.ftue.api package io.element.android.features.ftue.api
import com.bumble.appyx.core.modality.BuildContext import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
interface FtueEntryPoint : FeatureEntryPoint { interface FtueEntryPoint : SimpleFeatureEntryPoint
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun build(): Node
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2023, 2024 New Vector Ltd. * Copyright 2023, 2024 New Vector Ltd.
@ -46,14 +47,7 @@ dependencies {
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(projects.appconfig) implementation(projects.appconfig)
testImplementation(libs.test.junit) testCommonDependencies(libs, true)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.analytics.noop) testImplementation(projects.services.analytics.noop)
@ -61,5 +55,4 @@ dependencies {
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.lockscreen.test) testImplementation(projects.features.lockscreen.test)
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
} }

View file

@ -9,7 +9,6 @@ package io.element.android.features.ftue.impl
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
@ -19,13 +18,7 @@ import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@Inject @Inject
class DefaultFtueEntryPoint : FtueEntryPoint { class DefaultFtueEntryPoint : FtueEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder { override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
val plugins = ArrayList<Plugin>() return parentNode.createNode<FtueFlowNode>(buildContext)
return object : FtueEntryPoint.NodeBuilder {
override fun build(): Node {
return parentNode.createNode<FtueFlowNode>(buildContext, plugins)
}
}
} }
} }

View file

@ -0,0 +1,59 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.ftue.impl
import android.content.Context
import android.content.Intent
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultFtueEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultFtueEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
FtueFlowNode(
buildContext = buildContext,
plugins = plugins,
analyticsEntryPoint = { _, _ -> lambdaError() },
ftueState = createDefaultFtueService(),
analyticsService = FakeAnalyticsService(),
lockScreenEntryPoint = object : LockScreenEntryPoint {
override fun nodeBuilder(
parentNode: com.bumble.appyx.core.node.Node,
buildContext: BuildContext,
navTarget: LockScreenEntryPoint.Target
): LockScreenEntryPoint.NodeBuilder {
lambdaError()
}
override fun pinUnlockIntent(context: Context): Intent {
lambdaError()
}
},
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(FtueFlowNode::class.java)
}
}

View file

@ -226,22 +226,22 @@ class DefaultFtueServiceTest {
resetPermissionLambda.assertions().isCalledOnce() resetPermissionLambda.assertions().isCalledOnce()
.with(value("android.permission.POST_NOTIFICATIONS")) .with(value("android.permission.POST_NOTIFICATIONS"))
} }
private fun TestScope.createDefaultFtueService(
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
// First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueService(
sessionCoroutineScope = backgroundScope,
sessionVerificationService = sessionVerificationService,
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
sessionPreferencesStore = sessionPreferencesStore,
)
} }
internal fun TestScope.createDefaultFtueService(
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
// First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueService(
sessionCoroutineScope = backgroundScope,
sessionVerificationService = sessionVerificationService,
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
sessionPreferencesStore = sessionPreferencesStore,
)

View file

@ -28,6 +28,5 @@ interface HomeEntryPoint : FeatureEntryPoint {
fun onSessionConfirmRecoveryKeyClick() fun onSessionConfirmRecoveryKeyClick()
fun onRoomSettingsClick(roomId: RoomId) fun onRoomSettingsClick(roomId: RoomId)
fun onReportBugClick() fun onReportBugClick()
fun onLogoutForNativeSlidingSyncMigrationNeeded()
} }
} }

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2022-2024 New Vector Ltd. * Copyright 2022-2024 New Vector Ltd.
@ -58,14 +59,7 @@ dependencies {
implementation(projects.libraries.previewutils) implementation(projects.libraries.previewutils)
api(projects.features.home.api) api(projects.features.home.api)
testImplementation(libs.androidx.compose.ui.test.junit) testCommonDependencies(libs, true)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.features.invite.test) testImplementation(projects.features.invite.test)
testImplementation(projects.features.logout.test) testImplementation(projects.features.logout.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
@ -80,5 +74,4 @@ dependencies {
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
} }

View file

@ -164,7 +164,7 @@ class HomeFlowNode(
stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false)) stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false))
} }
fun rootNode(buildContext: BuildContext): Node { private fun rootNode(buildContext: BuildContext): Node {
return node(buildContext) { modifier -> return node(buildContext) { modifier ->
val state by stateFlow.collectAsState() val state by stateFlow.collectAsState()
val activity = requireNotNull(LocalActivity.current) val activity = requireNotNull(LocalActivity.current)

View file

@ -16,7 +16,7 @@
<string name="screen_home_tab_spaces">"Espaços"</string> <string name="screen_home_tab_spaces">"Espaços"</string>
<string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string> <string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rejeitar convite"</string> <string name="screen_invites_decline_chat_title">"Rejeitar convite"</string>
<string name="screen_invites_decline_direct_chat_message">"Tem a certeza que queres rejeitar esta conversa privada com %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Tens a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string> <string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string>
<string name="screen_invites_empty_list">"Sem convites"</string> <string name="screen_invites_empty_list">"Sem convites"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string> <string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>
@ -33,6 +33,7 @@ Por enquanto, podes anular a seleção dos filtros para veres as tuas outras con
<string name="screen_roomlist_filter_invites">"Convites"</string> <string name="screen_roomlist_filter_invites">"Convites"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Não tens nenhum convite pendente."</string> <string name="screen_roomlist_filter_invites_empty_state_title">"Não tens nenhum convite pendente."</string>
<string name="screen_roomlist_filter_low_priority">"Prioridade baixa"</string> <string name="screen_roomlist_filter_low_priority">"Prioridade baixa"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Ainda não tens conversas de prioridade baixa"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Podes anular a seleção dos filtros para veres as tuas outras conversas"</string> <string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Podes anular a seleção dos filtros para veres as tuas outras conversas"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Não tens nenhuma conversa selecionada"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"Não tens nenhuma conversa selecionada"</string>
<string name="screen_roomlist_filter_people">"Pessoas"</string> <string name="screen_roomlist_filter_people">"Pessoas"</string>

View file

@ -0,0 +1,58 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultHomeEntryPointTest {
@Test
fun `test node builder`() {
val entryPoint = DefaultHomeEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
HomeFlowNode(
buildContext = buildContext,
plugins = plugins,
matrixClient = FakeMatrixClient(),
presenter = createHomePresenter(),
inviteFriendsUseCase = { lambdaError() },
analyticsService = FakeAnalyticsService(),
acceptDeclineInviteView = { _, _, _, _ -> lambdaError() },
directLogoutView = { _ -> lambdaError() },
reportRoomEntryPoint = { _, _, _ -> lambdaError() },
declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() },
changeRoomMemberRolesEntryPoint = { _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
)
}
val callback = object : HomeEntryPoint.Callback {
override fun onRoomClick(roomId: RoomId) = lambdaError()
override fun onStartChatClick() = lambdaError()
override fun onSettingsClick() = lambdaError()
override fun onSetUpRecoveryClick() = lambdaError()
override fun onSessionConfirmRecoveryKeyClick() = lambdaError()
override fun onRoomSettingsClick(roomId: RoomId) = lambdaError()
override fun onReportBugClick() = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
assertThat(result).isInstanceOf(HomeFlowNode::class.java)
assertThat(result.plugins).contains(callback)
}
}

View file

@ -175,24 +175,24 @@ class HomePresenterTest {
assertThat(finalState.showNavigationBar).isFalse() assertThat(finalState.showNavigationBar).isFalse()
} }
} }
private fun createHomePresenter(
client: MatrixClient = FakeMatrixClient(),
syncService: SyncService = FakeSyncService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) },
indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
) = HomePresenter(
client = client,
syncService = syncService,
snackbarDispatcher = snackbarDispatcher,
indicatorService = indicatorService,
logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
)
} }
internal fun createHomePresenter(
client: MatrixClient = FakeMatrixClient(),
syncService: SyncService = FakeSyncService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) },
indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
) = HomePresenter(
client = client,
syncService = syncService,
snackbarDispatcher = snackbarDispatcher,
indicatorService = indicatorService,
logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
)

View file

@ -11,7 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
interface AcceptDeclineInviteView { fun interface AcceptDeclineInviteView {
@Composable @Composable
fun Render( fun Render(
state: AcceptDeclineInviteState, state: AcceptDeclineInviteState,

View file

@ -12,6 +12,6 @@ import com.bumble.appyx.core.node.Node
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint { fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
fun createNode(parentNode: Node, buildContext: BuildContext, inviteData: InviteData): Node fun createNode(parentNode: Node, buildContext: BuildContext, inviteData: InviteData): Node
} }

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2023, 2024 New Vector Ltd. * Copyright 2023, 2024 New Vector Ltd.
@ -36,17 +37,9 @@ dependencies {
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.libraries.push.api) implementation(projects.libraries.push.api)
testImplementation(libs.test.junit) testCommonDependencies(libs, true)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.features.invite.test) testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -35,7 +35,7 @@ class DeclineAndBlockPresenter(
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<DeclineAndBlockState> { ) : Presenter<DeclineAndBlockState> {
@AssistedFactory @AssistedFactory
interface Factory { fun interface Factory {
fun create(inviteData: InviteData): DeclineAndBlockPresenter fun create(inviteData: InviteData): DeclineAndBlockPresenter
} }

View file

@ -7,7 +7,7 @@
<string name="screen_decline_and_block_title">"Rejeitar e bloquear"</string> <string name="screen_decline_and_block_title">"Rejeitar e bloquear"</string>
<string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string> <string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rejeitar convite"</string> <string name="screen_invites_decline_chat_title">"Rejeitar convite"</string>
<string name="screen_invites_decline_direct_chat_message">"Tem a certeza que queres rejeitar esta conversa privada com %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Tens a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string> <string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string>
<string name="screen_invites_empty_list">"Sem convites"</string> <string name="screen_invites_empty_list">"Sem convites"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string> <string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>

View file

@ -11,11 +11,11 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.impl.DeclineInvite import io.element.android.features.invite.impl.DeclineInvite
import io.element.android.features.invite.impl.fake.FakeDeclineInvite import io.element.android.features.invite.impl.fake.FakeDeclineInvite
import io.element.android.features.invite.test.anInviteData
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -148,28 +148,16 @@ class DeclineAndBlockPresenterTest {
.isCalledOnce() .isCalledOnce()
.with(value(A_ROOM_ID), value(true), value(false), value("")) .with(value(A_ROOM_ID), value(true), value(false), value(""))
} }
}
private fun anInviteData(
roomId: RoomId = A_ROOM_ID, internal fun createDeclineAndBlockPresenter(
name: String = A_ROOM_NAME, inviteData: InviteData = anInviteData(),
isDm: Boolean = false, declineInvite: DeclineInvite = FakeDeclineInvite(),
): InviteData { snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
return InviteData( ): DeclineAndBlockPresenter {
roomId = roomId, return DeclineAndBlockPresenter(
roomName = name, inviteData = inviteData,
isDm = isDm, declineInvite = declineInvite,
) snackbarDispatcher = snackbarDispatcher,
} )
private fun createDeclineAndBlockPresenter(
inviteData: InviteData = anInviteData(),
declineInvite: DeclineInvite = FakeDeclineInvite(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
): DeclineAndBlockPresenter {
return DeclineAndBlockPresenter(
inviteData = inviteData,
declineInvite = declineInvite,
snackbarDispatcher = snackbarDispatcher,
)
}
} }

View file

@ -0,0 +1,41 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.invite.impl.declineandblock
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.test.anInviteData
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultDeclineAndBlockEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultDeclineAndBlockEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
DeclineAndBlockNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { inviteData -> createDeclineAndBlockPresenter() }
)
}
val inviteData = anInviteData()
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
inviteData = inviteData
)
assertThat(result).isInstanceOf(DeclineAndBlockNode::class.java)
assertThat(result.plugins).contains(DeclineAndBlockNode.Inputs(inviteData))
}
}

View file

@ -7,8 +7,11 @@
package io.element.android.features.invitepeople.api package io.element.android.features.invitepeople.api
import io.element.android.libraries.architecture.AsyncAction
interface InvitePeopleState { interface InvitePeopleState {
val canInvite: Boolean val canInvite: Boolean
val isSearchActive: Boolean val isSearchActive: Boolean
val sendInvitesAction: AsyncAction<Unit>
val eventSink: (InvitePeopleEvents) -> Unit val eventSink: (InvitePeopleEvents) -> Unit
} }

View file

@ -8,28 +8,33 @@
package io.element.android.features.invitepeople.api package io.element.android.features.invitepeople.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> { class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> {
override val values: Sequence<InvitePeopleState> override val values: Sequence<InvitePeopleState>
get() = sequenceOf( get() = sequenceOf(
aPreviewInvitePeopleState(), aPreviewInvitePeopleState(),
aPreviewInvitePeopleState(canInvite = true), aPreviewInvitePeopleState(canInvite = true),
aPreviewInvitePeopleState(isSearchActive = true) aPreviewInvitePeopleState(isSearchActive = true),
aPreviewInvitePeopleState(sendInvitesAction = AsyncAction.Loading),
) )
} }
private data class PreviewInvitePeopleState( private data class PreviewInvitePeopleState(
override val canInvite: Boolean, override val canInvite: Boolean,
override val isSearchActive: Boolean, override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>,
override val eventSink: (InvitePeopleEvents) -> Unit, override val eventSink: (InvitePeopleEvents) -> Unit,
) : InvitePeopleState ) : InvitePeopleState
private fun aPreviewInvitePeopleState( private fun aPreviewInvitePeopleState(
canInvite: Boolean = false, canInvite: Boolean = false,
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (InvitePeopleEvents) -> Unit = {}, eventSink: (InvitePeopleEvents) -> Unit = {},
) = PreviewInvitePeopleState( ) = PreviewInvitePeopleState(
canInvite = canInvite, canInvite = canInvite,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
sendInvitesAction = sendInvitesAction,
eventSink = eventSink eventSink = eventSink
) )

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2022-2024 New Vector Ltd. * Copyright 2022-2024 New Vector Ltd.
@ -37,17 +38,8 @@ dependencies {
implementation(projects.services.apperror.api) implementation(projects.services.apperror.api)
api(projects.features.invitepeople.api) api(projects.features.invitepeople.api)
testImplementation(libs.test.junit) testCommonDependencies(libs, true)
testImplementation(libs.test.mockk)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.services.apperror.test) testImplementation(projects.services.apperror.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -23,9 +23,11 @@ import dev.zacsweers.metro.Inject
import io.element.android.features.invitepeople.api.InvitePeopleEvents import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeoplePresenter import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleState import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.map import io.element.android.libraries.architecture.map
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ -73,6 +75,8 @@ class DefaultInvitePeoplePresenter(
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) } val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) { val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) {
if (joinedRoom == null) { if (joinedRoom == null) {
val result = matrixClient.getJoinedRoom(roomId) val result = matrixClient.getJoinedRoom(roomId)
@ -116,7 +120,7 @@ class DefaultInvitePeoplePresenter(
} }
is InvitePeopleEvents.SendInvites -> { is InvitePeopleEvents.SendInvites -> {
room.dataOrNull()?.let { room.dataOrNull()?.let {
sessionCoroutineScope.sendInvites(it, selectedUsers.value) sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
} }
} }
is InvitePeopleEvents.CloseSearch -> { is InvitePeopleEvents.CloseSearch -> {
@ -128,12 +132,13 @@ class DefaultInvitePeoplePresenter(
return DefaultInvitePeopleState( return DefaultInvitePeopleState(
room = room.map { }, room = room.map { },
canInvite = selectedUsers.value.isNotEmpty(), canInvite = selectedUsers.value.isNotEmpty() && !sendInvitesAction.value.isLoading(),
selectedUsers = selectedUsers.value, selectedUsers = selectedUsers.value,
searchQuery = searchQuery, searchQuery = searchQuery,
isSearchActive = searchActive, isSearchActive = searchActive,
searchResults = searchResults.value, searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value, showSearchLoader = showSearchLoader.value,
sendInvitesAction = sendInvitesAction.value,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -141,16 +146,21 @@ class DefaultInvitePeoplePresenter(
private fun CoroutineScope.sendInvites( private fun CoroutineScope.sendInvites(
room: JoinedRoom, room: JoinedRoom,
selectedUsers: List<MatrixUser>, selectedUsers: List<MatrixUser>,
sendInvitesAction: MutableState<AsyncAction<Unit>>,
) = launch { ) = launch {
val anyInviteFailed = selectedUsers sendInvitesAction.runUpdatingState {
.map { room.inviteUserById(it.userId) } val anyInviteFailed = selectedUsers
.any { it.isFailure } .map { room.inviteUserById(it.userId) }
.any { it.isFailure }
if (anyInviteFailed) { if (anyInviteFailed) {
appErrorStateService.showError( appErrorStateService.showError(
titleRes = CommonStrings.common_unable_to_invite_title, titleRes = CommonStrings.common_unable_to_invite_title,
bodyRes = CommonStrings.common_unable_to_invite_message, bodyRes = CommonStrings.common_unable_to_invite_message,
) )
}
Result.success(Unit)
} }
} }

View file

@ -9,6 +9,7 @@ package io.element.android.features.invitepeople.impl
import io.element.android.features.invitepeople.api.InvitePeopleEvents import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -22,5 +23,6 @@ data class DefaultInvitePeopleState(
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>, val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
override val isSearchActive: Boolean, override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>,
override val eventSink: (InvitePeopleEvents) -> Unit override val eventSink: (InvitePeopleEvents) -> Unit
) : InvitePeopleState ) : InvitePeopleState

View file

@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl package io.element.android.features.invitepeople.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -68,6 +69,11 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
showSearchLoader = true, showSearchLoader = true,
), ),
aDefaultInvitePeopleState(room = AsyncData.Failure(Exception("Room not found"))), aDefaultInvitePeopleState(room = AsyncData.Failure(Exception("Room not found"))),
aDefaultInvitePeopleState(
canInvite = false,
selectedUsers = aMatrixUserList().toImmutableList(),
sendInvitesAction = AsyncAction.Loading,
),
) )
} }
@ -93,6 +99,7 @@ private fun aDefaultInvitePeopleState(
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(), selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
showSearchLoader: Boolean = false, showSearchLoader: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
): DefaultInvitePeopleState { ): DefaultInvitePeopleState {
return DefaultInvitePeopleState( return DefaultInvitePeopleState(
room = room, room = room,
@ -102,6 +109,7 @@ private fun aDefaultInvitePeopleState(
selectedUsers = selectedUsers, selectedUsers = selectedUsers,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader, showSearchLoader = showSearchLoader,
sendInvitesAction = sendInvitesAction,
eventSink = {}, eventSink = {},
) )
} }

View file

@ -409,10 +409,23 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
// Send invites // Send invites
initialState.eventSink(InvitePeopleEvents.SendInvites) initialState.eventSink(InvitePeopleEvents.SendInvites)
// Can't invite in the loading state
awaitItem().run {
assertThat(sendInvitesAction.isLoading()).isTrue()
assertThat(canInvite).isFalse()
}
delay(1_000) delay(1_000)
inviteUserResult.assertions().isCalledOnce().with( inviteUserResult.assertions().isCalledOnce().with(
value(selectedUser.userId) value(selectedUser.userId)
) )
// Can invite again once the action is finished
awaitItem().run {
assertThat(sendInvitesAction.isReady()).isTrue()
assertThat(canInvite).isTrue()
}
} }
} }
@ -445,6 +458,13 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
// Send invites // Send invites
initialState.eventSink(InvitePeopleEvents.SendInvites) initialState.eventSink(InvitePeopleEvents.SendInvites)
// Can't invite in the loading state
awaitItem().run {
assertThat(sendInvitesAction.isLoading()).isTrue()
assertThat(canInvite).isFalse()
}
delay(1_000) delay(1_000)
inviteUserResult.assertions().isCalledOnce().with( inviteUserResult.assertions().isCalledOnce().with(
value(selectedUser.userId) value(selectedUser.userId)
@ -455,6 +475,12 @@ internal class DefaultInvitePeoplePresenterTest {
value(CommonStrings.common_unable_to_invite_title), value(CommonStrings.common_unable_to_invite_title),
value(CommonStrings.common_unable_to_invite_message) value(CommonStrings.common_unable_to_invite_message)
) )
// Can invite again once the action is finished
awaitItem().run {
assertThat(sendInvitesAction.isReady()).isTrue()
assertThat(canInvite).isTrue()
}
} }
} }

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2024 New Vector Ltd. * Copyright 2024 New Vector Ltd.
@ -38,16 +39,8 @@ dependencies {
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.appconfig) implementation(projects.appconfig)
testImplementation(libs.test.junit) testCommonDependencies(libs, true)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.invite.test) testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -76,7 +76,7 @@ class JoinRoomPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
) : Presenter<JoinRoomState> { ) : Presenter<JoinRoomState> {
interface Factory { fun interface Factory {
fun create( fun create(
roomId: RoomId, roomId: RoomId,
roomIdOrAlias: RoomIdOrAlias, roomIdOrAlias: RoomIdOrAlias,

View file

@ -20,7 +20,7 @@
<string name="screen_join_room_knock_action">"Bater à porta"</string> <string name="screen_join_room_knock_action">"Bater à porta"</string>
<string name="screen_join_room_knock_message_characters_count">"%1$d de %2$d caracteres permitidos"</string> <string name="screen_join_room_knock_message_characters_count">"%1$d de %2$d caracteres permitidos"</string>
<string name="screen_join_room_knock_message_description">"Mensagem (opcional)"</string> <string name="screen_join_room_knock_message_description">"Mensagem (opcional)"</string>
<string name="screen_join_room_knock_sent_description">"Irá receber um convite para participar na sala se seu pedido for aceite."</string> <string name="screen_join_room_knock_sent_description">"Irás receber um convite para participar na sala se o pedido for aceite."</string>
<string name="screen_join_room_knock_sent_title">"Pedido de adesão enviado"</string> <string name="screen_join_room_knock_sent_title">"Pedido de adesão enviado"</string>
<string name="screen_join_room_loading_alert_message">"Não conseguimos exibir a pré-visualização da sala. Isso pode ser devido a problemas de rede ou servidor."</string> <string name="screen_join_room_loading_alert_message">"Não conseguimos exibir a pré-visualização da sala. Isso pode ser devido a problemas de rede ou servidor."</string>
<string name="screen_join_room_loading_alert_title">"Não foi possível exibir a pré-visualização desta sala"</string> <string name="screen_join_room_loading_alert_title">"Não foi possível exibir a pré-visualização desta sala"</string>

View file

@ -0,0 +1,59 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.joinroom.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
import java.util.Optional
class DefaultJoinRoomEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultJoinRoomEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
JoinRoomFlowNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { _, _, _, _, _ -> createJoinRoomPresenter() },
acceptDeclineInviteView = { _, _, _, _ -> lambdaError() },
declineAndBlockEntryPoint = object : DeclineInviteAndBlockEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext, inviteData: InviteData) = lambdaError()
}
)
}
val inputs = JoinRoomEntryPoint.Inputs(
roomId = A_ROOM_ID,
roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
roomDescription = Optional.ofNullable(null),
serverNames = emptyList(),
trigger = JoinedRoom.Trigger.RoomDirectory,
)
val result = entryPoint.createNode(parentNode, BuildContext.root(null), inputs)
assertThat(result).isInstanceOf(JoinRoomFlowNode::class.java)
assertThat(result.plugins).contains(inputs)
}
}

View file

@ -1034,39 +1034,6 @@ class JoinRoomPresenterTest {
} }
} }
private fun createJoinRoomPresenter(
roomId: RoomId = A_ROOM_ID,
roomDescription: Optional<RoomDescription> = Optional.empty(),
serverNames: List<String> = emptyList(),
trigger: JoinedRoom.Trigger = JoinedRoom.Trigger.Invite,
matrixClient: MatrixClient = FakeMatrixClient(),
joinRoomLambda: (RoomIdOrAlias, List<String>, JoinedRoom.Trigger) -> Result<Unit> = { _, _, _ ->
Result.success(Unit)
},
knockRoom: KnockRoom = FakeKnockRoom(),
cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(),
forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
roomIdOrAlias = roomId.toRoomIdOrAlias(),
roomDescription = roomDescription,
serverNames = serverNames,
trigger = trigger,
matrixClient = matrixClient,
joinRoom = FakeJoinRoom(joinRoomLambda),
knockRoom = knockRoom,
cancelKnockRoom = cancelKnockRoom,
forgetRoom = forgetRoom,
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
seenInvitesStore = seenInvitesStore,
)
}
private fun aRoomDescription( private fun aRoomDescription(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME, name: String? = A_ROOM_NAME,
@ -1087,3 +1054,36 @@ class JoinRoomPresenterTest {
) )
} }
} }
internal fun createJoinRoomPresenter(
roomId: RoomId = A_ROOM_ID,
roomDescription: Optional<RoomDescription> = Optional.empty(),
serverNames: List<String> = emptyList(),
trigger: JoinedRoom.Trigger = JoinedRoom.Trigger.Invite,
matrixClient: MatrixClient = FakeMatrixClient(),
joinRoomLambda: (RoomIdOrAlias, List<String>, JoinedRoom.Trigger) -> Result<Unit> = { _, _, _ ->
Result.success(Unit)
},
knockRoom: KnockRoom = FakeKnockRoom(),
cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(),
forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
roomIdOrAlias = roomId.toRoomIdOrAlias(),
roomDescription = roomDescription,
serverNames = serverNames,
trigger = trigger,
matrixClient = matrixClient,
joinRoom = FakeJoinRoom(joinRoomLambda),
knockRoom = knockRoom,
cancelKnockRoom = cancelKnockRoom,
forgetRoom = forgetRoom,
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
seenInvitesStore = seenInvitesStore,
)
}

View file

@ -6,6 +6,7 @@
*/ */
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
@ -33,15 +34,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
testImplementation(libs.test.junit) testCommonDependencies(libs, true)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -0,0 +1,36 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.list
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultKnockRequestsListEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultKnockRequestsListEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
KnockRequestsListNode(
buildContext = buildContext,
plugins = plugins,
presenter = createKnockRequestsListPresenter(),
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(KnockRequestsListNode::class.java)
}
}

View file

@ -286,19 +286,19 @@ class KnockRequestsListPresenterTest {
assert(acceptFailureLambda).isCalledOnce() assert(acceptFailureLambda).isCalledOnce()
assert(acceptSuccessLambda).isCalledOnce() assert(acceptSuccessLambda).isCalledOnce()
} }
}
private fun TestScope.createKnockRequestsListPresenter(
canAccept: Boolean = true, internal fun TestScope.createKnockRequestsListPresenter(
canDecline: Boolean = true, canAccept: Boolean = true,
canBan: Boolean = true, canDecline: Boolean = true,
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList()) canBan: Boolean = true,
): KnockRequestsListPresenter { knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList())
val knockRequestsService = KnockRequestsService( ): KnockRequestsListPresenter {
knockRequestsFlow = knockRequestsFlow, val knockRequestsService = KnockRequestsService(
coroutineScope = backgroundScope, knockRequestsFlow = knockRequestsFlow,
isKnockFeatureEnabledFlow = flowOf(true), coroutineScope = backgroundScope,
permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)), isKnockFeatureEnabledFlow = flowOf(true),
) permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)),
return KnockRequestsListPresenter(knockRequestsService = knockRequestsService) )
} return KnockRequestsListPresenter(knockRequestsService = knockRequestsService)
} }

View file

@ -11,7 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
interface LeaveRoomRenderer { fun interface LeaveRoomRenderer {
@Composable @Composable
fun Render( fun Render(
state: LeaveRoomState, state: LeaveRoomState,

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2022-2024 New Vector Ltd. * Copyright 2022-2024 New Vector Ltd.
@ -26,13 +27,8 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.push.api) implementation(projects.libraries.push.api)
testImplementation(libs.test.junit) testCommonDependencies(libs)
testImplementation(libs.coroutines.test)
testImplementation(libs.coroutines.core) testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.tests.testutils)
} }

View file

@ -7,9 +7,6 @@
package io.element.android.features.licenses.api package io.element.android.features.licenses.api
import com.bumble.appyx.core.modality.BuildContext import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
import com.bumble.appyx.core.node.Node
interface OpenSourceLicensesEntryPoint { interface OpenSourceLicensesEntryPoint : SimpleFeatureEntryPoint
fun getNode(node: Node, buildContext: BuildContext): Node
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2022-2024 New Vector Ltd. * Copyright 2022-2024 New Vector Ltd.
@ -26,12 +27,8 @@ dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
api(projects.features.licenses.api) api(projects.features.licenses.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testCommonDependencies(libs)
testImplementation(libs.coroutines.core) testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
} }

View file

@ -18,7 +18,7 @@ import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@Inject @Inject
class DefaultOpenSourcesLicensesEntryPoint : OpenSourceLicensesEntryPoint { class DefaultOpenSourcesLicensesEntryPoint : OpenSourceLicensesEntryPoint {
override fun getNode(node: Node, buildContext: BuildContext): Node { override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return node.createNode<DependenciesFlowNode>(buildContext) return parentNode.createNode<DependenciesFlowNode>(buildContext)
} }
} }

View file

@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultOpenSourcesLicensesEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultOpenSourcesLicensesEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
DependenciesFlowNode(
buildContext = buildContext,
plugins = plugins,
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(DependenciesFlowNode::class.java)
}
}

View file

@ -8,6 +8,7 @@
import config.BuildTimeConfig import config.BuildTimeConfig
import extension.buildConfigFieldStr import extension.buildConfigFieldStr
import extension.readLocalProperty import extension.readLocalProperty
import extension.testCommonDependencies
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
@ -70,6 +71,5 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose) implementation(libs.coil.compose)
testImplementation(libs.test.junit) testCommonDependencies(libs)
testImplementation(libs.test.truth)
} }

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2022-2024 New Vector Ltd. * Copyright 2022-2024 New Vector Ltd.
@ -37,17 +38,10 @@ dependencies {
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.accompanist.permission) implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testCommonDependencies(libs, true)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.testtags) testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test) testImplementation(projects.features.messages.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -10,7 +10,7 @@ package io.element.android.features.location.impl.common.permissions
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
interface PermissionsPresenter : Presenter<PermissionsState> { interface PermissionsPresenter : Presenter<PermissionsState> {
interface Factory { fun interface Factory {
fun create(permissions: List<String>): PermissionsPresenter fun create(permissions: List<String>): PermissionsPresenter
} }
} }

View file

@ -47,7 +47,7 @@ class SendLocationPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) : Presenter<SendLocationState> { ) : Presenter<SendLocationState> {
@AssistedFactory @AssistedFactory
interface Factory { fun interface Factory {
fun create(timelineMode: Timeline.Mode): SendLocationPresenter fun create(timelineMode: Timeline.Mode): SendLocationPresenter
} }

View file

@ -25,10 +25,10 @@ import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@Inject @Inject
class ShowLocationNode( class ShowLocationNode(
presenterFactory: ShowLocationPresenter.Factory,
analyticsService: AnalyticsService,
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: ShowLocationPresenter.Factory,
analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
init { init {
lifecycle.subscribe( lifecycle.subscribe(

View file

@ -28,14 +28,14 @@ import io.element.android.libraries.core.meta.BuildMeta
@Inject @Inject
class ShowLocationPresenter( class ShowLocationPresenter(
@Assisted private val location: Location,
@Assisted private val description: String?,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions, private val locationActions: LocationActions,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
@Assisted private val location: Location,
@Assisted private val description: String?
) : Presenter<ShowLocationState> { ) : Presenter<ShowLocationState> {
@AssistedFactory @AssistedFactory
interface Factory { fun interface Factory {
fun create(location: Location, description: String?): ShowLocationPresenter fun create(location: Location, description: String?): ShowLocationPresenter
} }

View file

@ -0,0 +1,55 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.send
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultSendLocationEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultSendLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
SendLocationNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { timelineMode: Timeline.Mode ->
SendLocationPresenter(
permissionsPresenterFactory = { FakePermissionsPresenter() },
room = FakeJoinedRoom(),
timelineMode = timelineMode,
analyticsService = FakeAnalyticsService(),
messageComposerContext = FakeMessageComposerContext(),
locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(),
)
},
analyticsService = FakeAnalyticsService(),
)
}
val timelineMode = Timeline.Mode.Live
val result = entryPoint.builder(timelineMode)
.build(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(SendLocationNode::class.java)
assertThat(result.plugins).contains(SendLocationNode.Inputs(timelineMode))
}
}

View file

@ -0,0 +1,58 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.show
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultShowLocationEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultShowLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
ShowLocationNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { location: Location, description: String? ->
ShowLocationPresenter(
permissionsPresenterFactory = { FakePermissionsPresenter() },
locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(),
location = location,
description = description,
)
},
analyticsService = FakeAnalyticsService(),
)
}
val inputs = ShowLocationEntryPoint.Inputs(
location = Location(37.4219983, -122.084, 10f),
description = "My location",
)
val result = entryPoint.createNode(
parentNode,
BuildContext.root(null),
inputs = inputs,
)
assertThat(result).isInstanceOf(ShowLocationNode::class.java)
assertThat(result.plugins).contains(inputs)
}
}

View file

@ -37,10 +37,10 @@ class ShowLocationPresenterTest {
permissionsPresenterFactory = object : PermissionsPresenter.Factory { permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter
}, },
fakeLocationActions, locationActions = fakeLocationActions,
fakeBuildMeta, buildMeta = fakeBuildMeta,
location, location = location,
A_DESCRIPTION, description = A_DESCRIPTION,
) )
@Test @Test

View file

@ -10,7 +10,7 @@ package io.element.android.features.location.test
import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.LocationService
class FakeLocationService( class FakeLocationService(
private val isServiceAvailable: Boolean, private val isServiceAvailable: Boolean = false,
) : LocationService { ) : LocationService {
override fun isServiceAvailable() = isServiceAvailable override fun isServiceAvailable() = isServiceAvailable
} }

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2023, 2024 New Vector Ltd. * Copyright 2023, 2024 New Vector Ltd.
@ -44,21 +45,12 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.biometric) implementation(libs.androidx.biometric)
testImplementation(libs.test.junit) testCommonDependencies(libs, true)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.cryptography.test) testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl) testImplementation(projects.libraries.cryptography.impl)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.features.logout.test) testImplementation(projects.features.logout.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -174,7 +174,7 @@ class PinUnlockPresenter(
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<Unit>>) = launch { private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<Unit>>) = launch {
suspend { suspend {
logoutUseCase.logout(ignoreSdkError = true) logoutUseCase.logoutAll(ignoreSdkError = true)
}.runCatchingUpdatingState(signOutAction) }.runCatchingUpdatingState(signOutAction)
} }
} }

View file

@ -0,0 +1,25 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.lockscreen.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultLockScreenEntryPointIntentTest {
@Test
fun `test pin unlock intent`() {
val entryPoint = DefaultLockScreenEntryPoint()
val result = entryPoint.pinUnlockIntent(InstrumentationRegistry.getInstrumentation().context)
assertThat(result.component?.className).isEqualTo(PinUnlockActivity::class.qualifiedName)
}
}

View file

@ -0,0 +1,68 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.lockscreen.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultLockScreenEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder Setup`() {
val entryPoint = DefaultLockScreenEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
LockScreenFlowNode(
buildContext = buildContext,
plugins = plugins,
)
}
val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupDone() = lambdaError()
}
val navTarget = LockScreenEntryPoint.Target.Setup
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null), navTarget)
.callback(callback)
.build()
assertThat(result).isInstanceOf(LockScreenFlowNode::class.java)
assertThat(result.plugins).contains(LockScreenFlowNode.Inputs(LockScreenFlowNode.NavTarget.Setup))
assertThat(result.plugins).contains(callback)
}
@Test
fun `test node builder Settings`() {
val entryPoint = DefaultLockScreenEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
LockScreenFlowNode(
buildContext = buildContext,
plugins = plugins,
)
}
val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupDone() = lambdaError()
}
val navTarget = LockScreenEntryPoint.Target.Settings
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null), navTarget)
.callback(callback)
.build()
assertThat(result).isInstanceOf(LockScreenFlowNode::class.java)
assertThat(result.plugins).contains(LockScreenFlowNode.Inputs(LockScreenFlowNode.NavTarget.Settings))
assertThat(result.plugins).contains(callback)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2022-2024 New Vector Ltd. * Copyright 2022-2024 New Vector Ltd.
@ -48,14 +49,7 @@ dependencies {
implementation(libs.serialization.json) implementation(libs.serialization.json)
api(projects.features.login.api) api(projects.features.login.api)
testImplementation(libs.test.junit) testCommonDependencies(libs, true)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.login.test) testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test) testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
@ -63,6 +57,4 @@ dependencies {
testImplementation(projects.libraries.oidc.test) testImplementation(projects.libraries.oidc.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.wellknown.test) testImplementation(projects.libraries.wellknown.test)
testImplementation(projects.tests.testutils)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -74,7 +74,7 @@ Tenta iniciar a sessão manualmente ou digitaliza o código QR com outro disposi
<string name="screen_qr_code_login_initial_state_item_3">"Seleciona %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Seleciona %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Ligar novo dispositivo”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Ligar novo dispositivo”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Lê o código QR com este dispositivo"</string> <string name="screen_qr_code_login_initial_state_item_4">"Lê o código QR com este dispositivo"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Disponível apenas se o seu fornecedor de conta o suportar."</string> <string name="screen_qr_code_login_initial_state_subtitle">"Disponível apenas se o teu operador de conta o permitir."</string>
<string name="screen_qr_code_login_initial_state_title">"Abre a %1$s noutro dispositivo para obteres o código QR"</string> <string name="screen_qr_code_login_initial_state_title">"Abre a %1$s noutro dispositivo para obteres o código QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Lê o código QR apresentado no outro dispositivo."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Lê o código QR apresentado no outro dispositivo."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Tentar novamente"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Tentar novamente"</string>

View file

@ -0,0 +1,56 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultLoginEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultLoginEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
LoginFlowNode(
buildContext = buildContext,
plugins = plugins,
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
oidcActionFlow = FakeOidcActionFlow(),
)
}
val callback = object : LoginEntryPoint.Callback {
override fun onReportProblem() = lambdaError()
}
val params = LoginEntryPoint.Params(
accountProvider = "ac",
loginHint = "lh",
)
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.params(params)
.callback(callback)
.build()
assertThat(result).isInstanceOf(LoginFlowNode::class.java)
assertThat(result.plugins).contains(LoginFlowNode.Params(params.accountProvider, params.loginHint))
assertThat(result.plugins).contains(callback)
}
}

View file

@ -8,16 +8,12 @@
package io.element.android.features.logout.api package io.element.android.features.logout.api
/** /**
* Used to trigger a log out of the current user from any part of the app. * Used to trigger a log out of the current user(s) from any part of the app.
*/ */
interface LogoutUseCase { interface LogoutUseCase {
/** /**
* Log out the current user and then perform any needed cleanup tasks. * Log out the current user(s) and then perform any needed cleanup tasks.
* @param ignoreSdkError if true, the SDK error will be ignored and the user will be logged out anyway. * @param ignoreSdkError if true, the SDK error will be ignored and the user will be logged out anyway.
*/ */
suspend fun logout(ignoreSdkError: Boolean) suspend fun logoutAll(ignoreSdkError: Boolean)
interface Factory {
fun create(sessionId: String): LogoutUseCase
}
} }

View file

@ -9,7 +9,7 @@ package io.element.android.features.logout.api.direct
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
interface DirectLogoutView { fun interface DirectLogoutView {
@Composable @Composable
fun Render(state: DirectLogoutState) fun Render(state: DirectLogoutState)
} }

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies
/* /*
* Copyright 2022-2024 New Vector Ltd. * Copyright 2022-2024 New Vector Ltd.
@ -35,15 +36,8 @@ dependencies {
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
api(projects.features.logout.api) api(projects.features.logout.api)
testImplementation(libs.test.junit) testCommonDependencies(libs, true)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils) testImplementation(projects.libraries.sessionStorage.test)
} }

Some files were not shown because too many files have changed in this diff Show more