Merge branch 'release/25.09.2' into main

This commit is contained in:
Benoit Marty 2025-09-24 14:35:40 +02:00
commit da5031b41c
884 changed files with 10425 additions and 4456 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

@ -1,3 +1,61 @@
Changes in Element X v25.09.1
=============================
## What's Changed
We have migrated our DI libraries from Dagger and Anvil to Metro. If you need more details on the migration steps, please read the [documentation](https://github.com/element-hq/element-x-android/blob/develop/docs/migration_to_metro.md).
### ✨ Features
* Allow replying to a message with an attachment by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5261
* Add emoji search to the reaction emoji picker by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5255
### 🙌 Improvements
* Spelling correction in Update FeatureFlags.kt by @escix in https://github.com/element-hq/element-x-android/pull/5232
* [a11y] Add content descriptions to room list item indicators by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5236
* [a11y] Add click action to the message bottom sheet handle by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5228
### 🐛 Bugfixes
* Reload member list after moderation actions by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5268
* Restore view log code by @bmarty in https://github.com/element-hq/element-x-android/pull/5294
* Detect mime type when picking a file by @bmarty in https://github.com/element-hq/element-x-android/pull/5291
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5249
* Sync Strings - new translations to Korean by @ElementBot in https://github.com/element-hq/element-x-android/pull/5286
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5290
### 🧱 Build
* Iterate on build chain by @bmarty in https://github.com/element-hq/element-x-android/pull/5272
* Cleanup our DI solution and add documentation about the migration to Metro by @bmarty in https://github.com/element-hq/element-x-android/pull/5287
* Revert agp to 8.11 by @bmarty in https://github.com/element-hq/element-x-android/pull/5311
### 🚧 In development 🚧
* Space: add content in home screen by @bmarty in https://github.com/element-hq/element-x-android/pull/5273
* Hide the home navigation bar if the user is not a member of any Space. by @bmarty in https://github.com/element-hq/element-x-android/pull/5292
### Dependency upgrades
* Update dependency org.maplibre.gl:android-sdk to v11.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5239
* Update dependency com.google.firebase:firebase-bom to v34.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5245
* Update dependency com.posthog:posthog-android to v3.21.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5238
* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5251
* Update plugin sonarqube to v6.3.1.5724 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5235
* Update android.gradle.plugin to v8.12.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5244
* Update dependency io.element.android:emojibase-bindings to v1.4.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5250
* Update actions/setup-python action to v6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5270
* Update dependency com.posthog:posthog-android to v3.21.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5275
* Migrate Anvil KSP to Metro by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5253
* Update actions/github-script action to v8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5284
* Update codecov/codecov-action action to v5.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5274
* Update dependency io.sentry:sentry-android to v8.21.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5293
### Others
* Remove LoginUserStory. by @bmarty in https://github.com/element-hq/element-x-android/pull/5237
* Update state in runUpdatingState when CancellationException occurs by @jbrenorv in https://github.com/element-hq/element-x-android/pull/5243
* Refactor: Move InMemorySessionStore to test module by @bmarty in https://github.com/element-hq/element-x-android/pull/5252
* Enable `largeHeap` option to have a larger max heap size by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5258
* Set a custom request config for the Client by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5266
* Set shortcut ID on received notifications to make them appear as a Conversation by @frebib in https://github.com/element-hq/element-x-android/pull/5192
* Improve management of shortcut ids. by @bmarty in https://github.com/element-hq/element-x-android/pull/5303
## New Contributors
* @escix made their first contribution in https://github.com/element-hq/element-x-android/pull/5232
* @jbrenorv made their first contribution in https://github.com/element-hq/element-x-android/pull/5243
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.09.0...v25.09.1
Changes in Element X v25.09.0 Changes in Element X v25.09.0
============================= =============================

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 @@
<locale android:name="el"/> <locale android:name="el"/>
<locale android:name="en"/> <locale android:name="en"/>
<locale android:name="en_US"/> <locale android:name="en_US"/>
<locale android:name="eo"/>
<locale android:name="es"/> <locale android:name="es"/>
<locale android:name="et"/> <locale android:name="et"/>
<locale android:name="eu"/> <locale android:name="eu"/>

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
@ -60,6 +59,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.startchat.api.StartChatEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
@ -118,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,
@ -276,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
} }
@ -323,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)
@ -334,7 +326,7 @@ class LoggedInFlowNode(
.build() .build()
} }
is NavTarget.Room -> { is NavTarget.Room -> {
val callback = object : JoinedRoomLoadedFlowNode.Callback { val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback {
override fun onOpenRoom(roomId: RoomId, serverNames: List<String>) { override fun onOpenRoom(roomId: RoomId, serverNames: List<String>) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames))
} }
@ -373,6 +365,11 @@ class LoggedInFlowNode(
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
} }
} }
val spaceCallback = object : SpaceEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
}
val inputs = RoomFlowNode.Inputs( val inputs = RoomFlowNode.Inputs(
roomIdOrAlias = navTarget.roomIdOrAlias, roomIdOrAlias = navTarget.roomIdOrAlias,
roomDescription = Optional.ofNullable(navTarget.roomDescription), roomDescription = Optional.ofNullable(navTarget.roomDescription),
@ -380,7 +377,7 @@ class LoggedInFlowNode(
trigger = Optional.ofNullable(navTarget.trigger), trigger = Optional.ofNullable(navTarget.trigger),
initialElement = navTarget.initialElement initialElement = navTarget.initialElement
) )
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, joinedRoomCallback, spaceCallback))
} }
is NavTarget.UserProfile -> { is NavTarget.UserProfile -> {
val callback = object : UserProfileEntryPoint.Callback { val callback = object : UserProfileEntryPoint.Callback {
@ -448,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)
@ -480,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

@ -30,7 +30,9 @@ import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.appnav.room.joined.LoadingRoomNodeView import io.element.android.appnav.room.joined.LoadingRoomNodeView
import io.element.android.features.joinroom.api.JoinRoomEntryPoint import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.space.api.SpaceEntryPoint
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.NodeInputs import io.element.android.libraries.architecture.NodeInputs
@ -52,7 +54,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
@ -72,6 +73,7 @@ class RoomFlowNode(
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint, private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val syncService: SyncService, private val syncService: SyncService,
private val membershipObserver: RoomMembershipObserver, private val membershipObserver: RoomMembershipObserver,
private val spaceEntryPoint: SpaceEntryPoint,
) : BaseFlowNode<RoomFlowNode.NavTarget>( ) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Loading, initialElement = NavTarget.Loading,
@ -106,6 +108,9 @@ class RoomFlowNode(
@Parcelize @Parcelize
data class JoinedRoom(val roomId: RoomId) : NavTarget data class JoinedRoom(val roomId: RoomId) : NavTarget
@Parcelize
data class JoinedSpace(val spaceId: RoomId) : NavTarget
} }
override fun onBuilt() { override fun onBuilt() {
@ -143,40 +148,28 @@ class RoomFlowNode(
.withPreviousValue() .withPreviousValue()
combine(currentMembershipFlow, isSpaceFlow) { (previousMembership, membership), isSpace -> combine(currentMembershipFlow, isSpaceFlow) { (previousMembership, membership), isSpace ->
Timber.d("Room membership: $membership") Timber.d("Room membership: $membership")
when (membership) { if (membership == CurrentUserMembership.JOINED) {
CurrentUserMembership.JOINED -> { if (isSpace) {
if (isSpace) { backstack.newRoot(NavTarget.JoinedSpace(spaceId = roomId))
// It should not happen, but probably due to an issue in the sliding sync, } else {
// we can have a space here in case the space has just been joined. backstack.newRoot(NavTarget.JoinedRoom(roomId))
// So navigate to the JoinRoom target for now, which will
// handle the space not supported screen
backstack.newRoot(
NavTarget.JoinRoom(
roomId = roomId,
serverNames = serverNames,
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite,
)
)
} else {
backstack.newRoot(NavTarget.JoinedRoom(roomId))
}
} }
else -> { } else {
if (membership == CurrentUserMembership.LEFT && previousMembership == CurrentUserMembership.JOINED) { val leavingFromCurrentDevice =
// The user left the room in this device, remove the room from the backstack membership == CurrentUserMembership.LEFT &&
if (!membershipUpdateFlow.first().isUserInRoom) { previousMembership == CurrentUserMembership.JOINED &&
navigateUp() membershipUpdateFlow.replayCache.lastOrNull()?.isUserInRoom == false
}
} else { if (leavingFromCurrentDevice) {
// Was invited or the room is not known, display the join room screen navigateUp()
backstack.newRoot( } else {
NavTarget.JoinRoom( backstack.newRoot(
roomId = roomId, NavTarget.JoinRoom(
serverNames = serverNames, roomId = roomId,
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite, serverNames = serverNames,
) trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite,
) )
} )
} }
} }
}.launchIn(lifecycleScope) }.launchIn(lifecycleScope)
@ -194,7 +187,7 @@ class RoomFlowNode(
) )
} }
} }
val params = RoomAliasResolverEntryPoint.Params(navTarget.roomAlias) val params = Params(navTarget.roomAlias)
roomAliasResolverEntryPoint.nodeBuilder(this, buildContext) roomAliasResolverEntryPoint.nodeBuilder(this, buildContext)
.callback(callback) .callback(callback)
.params(params) .params(params)
@ -218,6 +211,13 @@ class RoomFlowNode(
) )
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} }
is NavTarget.JoinedSpace -> {
val spaceCallback = plugins<SpaceEntryPoint.Callback>().single()
spaceEntryPoint.nodeBuilder(this, buildContext)
.inputs(SpaceEntryPoint.Inputs(roomId = navTarget.spaceId))
.callback(spaceCallback)
.build()
}
} }
} }

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

@ -93,6 +93,8 @@ allprojects {
// Fix compilation warning for annotations // Fix compilation warning for annotations
// See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details // See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
freeCompilerArgs.add("-Xannotation-default-target=first-only") freeCompilerArgs.add("-Xannotation-default-target=first-only")
// Opt-in to context receivers
freeCompilerArgs.add("-Xcontext-parameters")
} }
} }
} }

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

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

@ -41,5 +41,8 @@ data class WidgetMessage(
@SerialName("send_event") @SerialName("send_event")
SendEvent, SendEvent,
@SerialName("content_loaded")
ContentLoaded,
} }
} }

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

@ -79,7 +79,7 @@ class CallScreenPresenter(
val urlState = remember { mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized) } val urlState = remember { mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized) }
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) } val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) }
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) } val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
var isJoinedCall by rememberSaveable { mutableStateOf(false) } var isWidgetLoaded by rememberSaveable { mutableStateOf(false) }
var ignoreWebViewError by rememberSaveable { mutableStateOf(false) } var ignoreWebViewError by rememberSaveable { mutableStateOf(false) }
var webViewError by remember { mutableStateOf<String?>(null) } var webViewError by remember { mutableStateOf<String?>(null) }
val languageTag = languageTagProvider.provideLanguageTag() val languageTag = languageTagProvider.provideLanguageTag()
@ -139,8 +139,8 @@ class CallScreenPresenter(
if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) { if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) {
if (parsedMessage.action == WidgetMessage.Action.Close) { if (parsedMessage.action == WidgetMessage.Action.Close) {
close(callWidgetDriver.value, navigator) close(callWidgetDriver.value, navigator)
} else if (parsedMessage.action == WidgetMessage.Action.Join) { } else if (parsedMessage.action == WidgetMessage.Action.ContentLoaded) {
isJoinedCall = true isWidgetLoaded = true
} }
} }
} }
@ -151,8 +151,8 @@ class CallScreenPresenter(
// Wait for the call to be joined, if it takes too long, we display an error // Wait for the call to be joined, if it takes too long, we display an error
delay(10.seconds) delay(10.seconds)
if (!isJoinedCall) { if (!isWidgetLoaded) {
Timber.w("The call took too long to be joined. Displaying an error before exiting.") Timber.w("The call took too long to load. Displaying an error before exiting.")
// This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call // This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
webViewError = "" webViewError = ""
@ -165,10 +165,10 @@ class CallScreenPresenter(
is CallScreenEvents.Hangup -> { is CallScreenEvents.Hangup -> {
val widgetId = callWidgetDriver.value?.id val widgetId = callWidgetDriver.value?.id
val interceptor = messageInterceptor.value val interceptor = messageInterceptor.value
if (widgetId != null && interceptor != null && isJoinedCall) { if (widgetId != null && interceptor != null && isWidgetLoaded) {
// If the call was joined, we need to hang up first. Then the UI will be dismissed automatically. // If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
sendHangupMessage(widgetId, interceptor) sendHangupMessage(widgetId, interceptor)
isJoinedCall = false isWidgetLoaded = false
coroutineScope.launch { coroutineScope.launch {
// Wait for a couple of seconds to receive the hangup message // Wait for a couple of seconds to receive the hangup message
@ -198,7 +198,7 @@ class CallScreenPresenter(
urlState = urlState.value, urlState = urlState.value,
webViewError = webViewError, webViewError = webViewError,
userAgent = userAgent, userAgent = userAgent,
isCallActive = isJoinedCall, isCallActive = isWidgetLoaded,
isInWidgetMode = isInWidgetMode, isInWidgetMode = isInWidgetMode,
eventSink = { handleEvents(it) }, eventSink = { handleEvents(it) },
) )

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

@ -29,7 +29,7 @@ import kotlinx.serialization.json.Json
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds
/** /**
* This class manages the audio devices for a WebView. * This class manages the audio devices for a WebView.
@ -246,7 +246,6 @@ class WebViewAudioManager(
private fun registerWebViewDeviceSelectedCallback() { private fun registerWebViewDeviceSelectedCallback() {
val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge( val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge(
onAudioDeviceSelected = { selectedDeviceId -> onAudioDeviceSelected = { selectedDeviceId ->
Timber.d("Audio device selected in webview, id: $selectedDeviceId")
previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId } previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId }
audioManager.selectAudioDevice(selectedDeviceId) audioManager.selectAudioDevice(selectedDeviceId)
}, },
@ -254,7 +253,7 @@ class WebViewAudioManager(
coroutineScope.launch(Dispatchers.Main) { coroutineScope.launch(Dispatchers.Main) {
// Even with the callback, it seems like starting the audio takes a bit on the webview side, // Even with the callback, it seems like starting the audio takes a bit on the webview side,
// so we add an extra delay here to make sure it's ready // so we add an extra delay here to make sure it's ready
delay(500.milliseconds) delay(2.seconds)
// Calling this ahead of time makes the default audio device to not use the right audio stream // Calling this ahead of time makes the default audio device to not use the right audio stream
setAvailableAudioDevices() setAvailableAudioDevices()

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

@ -215,7 +215,7 @@ import kotlin.time.Duration.Companion.seconds
} }
@Test @Test
fun `present - a received 'joined' action makes the call to be active`() = runTest { fun `present - a received 'content loaded' action makes the call to be active`() = runTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
@ -238,7 +238,7 @@ import kotlin.time.Duration.Companion.seconds
messageInterceptor.givenInterceptedMessage( messageInterceptor.givenInterceptedMessage(
""" """
{ {
"action":"io.element.join", "action":"content_loaded",
"api":"fromWidget", "api":"fromWidget",
"widgetId":"1", "widgetId":"1",
"requestId":"1" "requestId":"1"

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

@ -2,7 +2,7 @@
<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_room_change_permissions_administrators">"Gweinyddwyr yn unig"</string> <string name="screen_room_change_permissions_administrators">"Gweinyddwyr yn unig"</string>
<string name="screen_room_change_permissions_ban_people">"Gwahardd pobl"</string> <string name="screen_room_change_permissions_ban_people">"Gwahardd pobl"</string>
<string name="screen_room_change_permissions_delete_messages">"Dileu negeseuon"</string> <string name="screen_room_change_permissions_delete_messages">"Tynnu negeseuon"</string>
<string name="screen_room_change_permissions_everyone">"Pawb"</string> <string name="screen_room_change_permissions_everyone">"Pawb"</string>
<string name="screen_room_change_permissions_invite_people">"Gwahodd pobl a derbyn ceisiadau i ymuno"</string> <string name="screen_room_change_permissions_invite_people">"Gwahodd pobl a derbyn ceisiadau i ymuno"</string>
<string name="screen_room_change_permissions_member_moderation">"Cymedroli aelodau"</string> <string name="screen_room_change_permissions_member_moderation">"Cymedroli aelodau"</string>
@ -17,13 +17,17 @@
<string name="screen_room_change_role_administrators_title">"Golygu Gweinyddwyr"</string> <string name="screen_room_change_role_administrators_title">"Golygu Gweinyddwyr"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych chi\'n hyrwyddo\'r defnyddiwr i gael yr un lefel pŵer â chi."</string> <string name="screen_room_change_role_confirm_add_admin_description">"Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych chi\'n hyrwyddo\'r defnyddiwr i gael yr un lefel pŵer â chi."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Ychwanegu Gweinyddwr?"</string> <string name="screen_room_change_role_confirm_add_admin_title">"Ychwanegu Gweinyddwr?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych yn trosglwyddo\'r berchnogaeth i\'r defnyddwyr a ddewiswyd. Unwaith y byddwch yn gadael bydd hyn yn barhaol."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Trosglwyddo perchnogaeth?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Gostwng"</string> <string name="screen_room_change_role_confirm_demote_self_action">"Gostwng"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Fyddwch chi ddim yn gallu dadwneud y newid hwn gan eich bod yn israddio eich hun, os mai chi yw\'r defnyddiwr breintiedig olaf yn yr ystafell bydd yn amhosibl adennill breintiau."</string> <string name="screen_room_change_role_confirm_demote_self_description">"Fyddwch chi ddim yn gallu dadwneud y newid hwn gan eich bod yn israddio eich hun, os mai chi yw\'r defnyddiwr breintiedig olaf yn yr ystafell bydd yn amhosibl adennill breintiau."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Israddio eich hun?"</string> <string name="screen_room_change_role_confirm_demote_self_title">"Israddio eich hun?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Yn aros)"</string> <string name="screen_room_change_role_invited_member_name">"%1$s (Yn aros)"</string>
<string name="screen_room_change_role_invited_member_name_android">"Yn aros"</string> <string name="screen_room_change_role_invited_member_name_android">"Yn aros"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Mae gan weinyddwyr freintiau cymedrolwr yn awtomatig"</string> <string name="screen_room_change_role_moderators_admin_section_footer">"Mae gan weinyddwyr freintiau cymedrolwr yn awtomatig"</string>
<string name="screen_room_change_role_moderators_owner_section_footer">"Mae gan berchnogion freintiau gweinyddwr yn awtomatig."</string>
<string name="screen_room_change_role_moderators_title">"Golygu Cymedrolwyr"</string> <string name="screen_room_change_role_moderators_title">"Golygu Cymedrolwyr"</string>
<string name="screen_room_change_role_owners_title">"Dewiswch Berchnogion"</string>
<string name="screen_room_change_role_section_administrators">"Gweinyddwyr"</string> <string name="screen_room_change_role_section_administrators">"Gweinyddwyr"</string>
<string name="screen_room_change_role_section_moderators">"Cymedrolwyr"</string> <string name="screen_room_change_role_section_moderators">"Cymedrolwyr"</string>
<string name="screen_room_change_role_section_users">"Aelodau"</string> <string name="screen_room_change_role_section_users">"Aelodau"</string>
@ -48,15 +52,18 @@
<string name="screen_room_member_list_pending_header_title">"Dan ystyriaeth"</string> <string name="screen_room_member_list_pending_header_title">"Dan ystyriaeth"</string>
<string name="screen_room_member_list_role_administrator">"Gweinyddwr"</string> <string name="screen_room_member_list_role_administrator">"Gweinyddwr"</string>
<string name="screen_room_member_list_role_moderator">"Cymedrolwr"</string> <string name="screen_room_member_list_role_moderator">"Cymedrolwr"</string>
<string name="screen_room_member_list_role_owner">"Perchennog"</string>
<string name="screen_room_member_list_room_members_header_title">"Aelodau\'r ystafell"</string> <string name="screen_room_member_list_room_members_header_title">"Aelodau\'r ystafell"</string>
<string name="screen_room_member_list_unbanning_user">"Dad-wahardd %1$s"</string> <string name="screen_room_member_list_unbanning_user">"Dad-wahardd %1$s"</string>
<string name="screen_room_roles_and_permissions_admins">"Gweinyddwyr"</string> <string name="screen_room_roles_and_permissions_admins">"Gweinyddwyr"</string>
<string name="screen_room_roles_and_permissions_admins_and_owners">"Gweinyddwyr a pherchnogion"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Newid fy rôl"</string> <string name="screen_room_roles_and_permissions_change_my_role">"Newid fy rôl"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Israddio aelod"</string> <string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Israddio aelod"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Israddio cymedrolwr"</string> <string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Israddio cymedrolwr"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Cymedroli aelodau"</string> <string name="screen_room_roles_and_permissions_member_moderation">"Cymedroli aelodau"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Negeseuon a chynnwys"</string> <string name="screen_room_roles_and_permissions_messages_and_content">"Negeseuon a chynnwys"</string>
<string name="screen_room_roles_and_permissions_moderators">"Cymedrolwyr"</string> <string name="screen_room_roles_and_permissions_moderators">"Cymedrolwyr"</string>
<string name="screen_room_roles_and_permissions_owners">"Perchnogion"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Caniatâd"</string> <string name="screen_room_roles_and_permissions_permissions_header">"Caniatâd"</string>
<string name="screen_room_roles_and_permissions_reset">"Ailosod caniatâd"</string> <string name="screen_room_roles_and_permissions_reset">"Ailosod caniatâd"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Ar ôl i chi ailosod caniatâd, byddwch yn colli\'r gosodiadau cyfredol."</string> <string name="screen_room_roles_and_permissions_reset_confirm_description">"Ar ôl i chi ailosod caniatâd, byddwch yn colli\'r gosodiadau cyfredol."</string>

View file

@ -16,12 +16,12 @@
<string name="screen_room_change_permissions_send_messages">"Viestien lähettäminen"</string> <string name="screen_room_change_permissions_send_messages">"Viestien lähettäminen"</string>
<string name="screen_room_change_role_administrators_title">"Muokkaa ylläpitäjiä"</string> <string name="screen_room_change_role_administrators_title">"Muokkaa ylläpitäjiä"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä."</string> <string name="screen_room_change_role_confirm_add_admin_description">"Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Lisää ylläpitäjä?"</string> <string name="screen_room_change_role_confirm_add_admin_title">"Lisätäänkö ylläpitäjä?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"Et voi kumota tätä toimintoa. Olet siirtämässä omistajuuden valituille käyttäjille. Kun poistut, muutos on pysyvä."</string> <string name="screen_room_change_role_confirm_change_owners_description">"Et voi kumota tätä toimintoa. Olet siirtämässä omistajuuden valituille käyttäjille. Kun poistut, muutos on pysyvä."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Siirretäänkö omistajuus?"</string> <string name="screen_room_change_role_confirm_change_owners_title">"Siirretäänkö omistajuus?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Alenna"</string> <string name="screen_room_change_role_confirm_demote_self_action">"Alenna"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Et voi perua tätä muutosta, koska olet alentamassa itseäsi. Jos olet viimeinen oikeutettu henkilö tässä huoneessa, oikeuksia ei voi enää saada takaisin."</string> <string name="screen_room_change_role_confirm_demote_self_description">"Et voi perua tätä muutosta, koska olet alentamassa itseäsi. Jos olet viimeinen oikeutettu henkilö tässä huoneessa, oikeuksia ei voi enää saada takaisin."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Alenna itsesi?"</string> <string name="screen_room_change_role_confirm_demote_self_title">"Haluatko alentaa itsesi?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Kutsuttu)"</string> <string name="screen_room_change_role_invited_member_name">"%1$s (Kutsuttu)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(Kutsuttu)"</string> <string name="screen_room_change_role_invited_member_name_android">"(Kutsuttu)"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Ylläpitäjillä on automaattisesti valvojan oikeudet"</string> <string name="screen_room_change_role_moderators_admin_section_footer">"Ylläpitäjillä on automaattisesti valvojan oikeudet"</string>
@ -32,7 +32,7 @@
<string name="screen_room_change_role_section_moderators">"Valvojat"</string> <string name="screen_room_change_role_section_moderators">"Valvojat"</string>
<string name="screen_room_change_role_section_users">"Jäsenet"</string> <string name="screen_room_change_role_section_users">"Jäsenet"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Sinulla on tallentamattomia muutoksia"</string> <string name="screen_room_change_role_unsaved_changes_description">"Sinulla on tallentamattomia muutoksia"</string>
<string name="screen_room_change_role_unsaved_changes_title">"Tallenna muutokset?"</string> <string name="screen_room_change_role_unsaved_changes_title">"Tallennetaanko muutokset?"</string>
<string name="screen_room_member_list_banned_empty">"Tässä huoneessa ei ole porttikieltoja"</string> <string name="screen_room_member_list_banned_empty">"Tässä huoneessa ei ole porttikieltoja"</string>
<plurals name="screen_room_member_list_header_title"> <plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d henkilö"</item> <item quantity="one">"%1$d henkilö"</item>

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

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_list_item_3">"Delete your account information from our server."</string>
</resources>

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

@ -15,9 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
interface FtueService { interface FtueService {
/** The current state of the FTUE. */ /** The current state of the FTUE. */
val state: StateFlow<FtueState> val state: StateFlow<FtueState>
/** Reset the FTUE state. */
suspend fun reset()
} }
/** The state of the FTUE. */ /** The state of the FTUE. */

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

@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
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 com.bumble.appyx.core.plugin.Plugin
@ -30,18 +29,16 @@ import io.element.android.features.ftue.impl.notifications.NotificationsOptInNod
import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
import io.element.android.features.ftue.impl.state.DefaultFtueService import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.state.InternalFtueState
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
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
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -49,9 +46,8 @@ import kotlinx.parcelize.Parcelize
class FtueFlowNode( class FtueFlowNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val ftueState: DefaultFtueService, private val defaultFtueService: DefaultFtueService,
private val analyticsEntryPoint: AnalyticsEntryPoint, private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService,
private val lockScreenEntryPoint: LockScreenEntryPoint, private val lockScreenEntryPoint: LockScreenEntryPoint,
) : BaseFlowNode<FtueFlowNode.NavTarget>( ) : BaseFlowNode<FtueFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -80,19 +76,11 @@ class FtueFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
defaultFtueService.ftueStepStateFlow
lifecycle.subscribe(onCreate = { .filterIsInstance(InternalFtueState.Incomplete::class)
moveToNextStepIfNeeded() .onEach {
}) showStep(it.nextStep)
}
analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope)
ftueState.isVerificationStatusKnown
.filter { it }
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
@ -104,7 +92,7 @@ class FtueFlowNode(
is NavTarget.SessionVerification -> { is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback { val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() { override fun onDone() {
moveToNextStepIfNeeded() defaultFtueService.onUserCompletedSessionVerification()
} }
} }
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback)) createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
@ -112,7 +100,7 @@ class FtueFlowNode(
NavTarget.NotificationsOptIn -> { NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback { val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() { override fun onNotificationsOptInFinished() {
moveToNextStepIfNeeded() defaultFtueService.updateFtueStep()
} }
} }
createNode<NotificationsOptInNode>(buildContext, listOf(callback)) createNode<NotificationsOptInNode>(buildContext, listOf(callback))
@ -123,7 +111,7 @@ class FtueFlowNode(
NavTarget.LockScreenSetup -> { NavTarget.LockScreenSetup -> {
val callback = object : LockScreenEntryPoint.Callback { val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupDone() { override fun onSetupDone() {
moveToNextStepIfNeeded() defaultFtueService.updateFtueStep()
} }
} }
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup) lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
@ -133,8 +121,8 @@ class FtueFlowNode(
} }
} }
private fun moveToNextStepIfNeeded() = lifecycleScope.launch { private fun showStep(ftueStep: FtueStep) {
when (ftueState.getNextStep()) { when (ftueStep) {
FtueStep.WaitingForInitialState -> { FtueStep.WaitingForInitialState -> {
backstack.newRoot(NavTarget.Placeholder) backstack.newRoot(NavTarget.Placeholder)
} }
@ -150,7 +138,6 @@ class FtueFlowNode(
FtueStep.LockscreenSetup -> { FtueStep.LockscreenSetup -> {
backstack.newRoot(NavTarget.LockScreenSetup) backstack.newRoot(NavTarget.LockScreenSetup)
} }
null -> Unit
} }
} }

View file

@ -9,13 +9,13 @@ package io.element.android.features.ftue.impl.state
import android.Manifest import android.Manifest
import android.os.Build import android.os.Build
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
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.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -26,61 +26,70 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
@Inject @Inject
class DefaultFtueService( class DefaultFtueService(
private val sdkVersionProvider: BuildVersionSdkIntProvider, private val sdkVersionProvider: BuildVersionSdkIntProvider,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val permissionStateProvider: PermissionStateProvider, private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService, private val lockScreenService: LockScreenService,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
) : FtueService { ) : FtueService {
override val state = MutableStateFlow<FtueState>(FtueState.Unknown) private val userNeedsToConfirmSessionVerificationSuccess = MutableStateFlow(false)
/** val ftueStepStateFlow = MutableStateFlow<InternalFtueState>(InternalFtueState.Unknown)
* This flow emits true when the FTUE flow is ready to be displayed.
* In this case, the FTUE flow is ready when the session verification status is known.
*/
val isVerificationStatusKnown = sessionVerificationService.sessionVerifiedStatus
.map { it != SessionVerifiedStatus.Unknown }
.distinctUntilChanged()
override suspend fun reset() { override val state = ftueStepStateFlow
analyticsService.reset() .mapState {
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { when (it) {
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS) is InternalFtueState.Unknown -> FtueState.Unknown
is InternalFtueState.Incomplete -> FtueState.Incomplete
is InternalFtueState.Complete -> FtueState.Complete
}
}
init {
combine(
sessionVerificationService.sessionVerifiedStatus.onEach { sessionVerifiedStatus ->
if (sessionVerifiedStatus == SessionVerifiedStatus.NotVerified) {
// Ensure we wait for the user to confirm the session verified screen before going further
userNeedsToConfirmSessionVerificationSuccess.value = true
}
},
userNeedsToConfirmSessionVerificationSuccess,
analyticsService.didAskUserConsentFlow.distinctUntilChanged(),
) {
updateFtueStep()
}
.launchIn(sessionCoroutineScope)
}
fun updateFtueStep() = sessionCoroutineScope.launch {
val step = getNextStep(null)
ftueStepStateFlow.value = when (step) {
null -> InternalFtueState.Complete
else -> InternalFtueState.Incomplete(step)
} }
} }
init { private suspend fun getNextStep(completedStep: FtueStep? = null): FtueStep? =
sessionVerificationService.sessionVerifiedStatus when (completedStep) {
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
}
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
null -> if (!isSessionVerificationStateReady()) { null -> if (!isSessionVerificationStateReady()) {
FtueStep.WaitingForInitialState FtueStep.WaitingForInitialState
} else { } else {
getNextStep(FtueStep.WaitingForInitialState) getNextStep(FtueStep.WaitingForInitialState)
} }
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) { FtueStep.WaitingForInitialState -> if (isSessionNotVerified() || userNeedsToConfirmSessionVerificationSuccess.value) {
FtueStep.SessionVerification FtueStep.SessionVerification
} else { } else {
getNextStep(FtueStep.SessionVerification) getNextStep(FtueStep.SessionVerification)
@ -108,9 +117,6 @@ class DefaultFtueService(
} }
private suspend fun isSessionNotVerified(): Boolean { private suspend fun isSessionNotVerified(): Boolean {
// Wait until the session verification status is known
isVerificationStatusKnown.filter { it }.first()
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification() return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
} }
@ -137,14 +143,8 @@ class DefaultFtueService(
return lockScreenService.isSetupRequired().first() return lockScreenService.isSetupRequired().first()
} }
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) fun onUserCompletedSessionVerification() {
internal suspend fun updateState() { userNeedsToConfirmSessionVerificationSuccess.value = false
val nextStep = getNextStep()
state.value = when {
// Final state, there aren't any more next steps
nextStep == null -> FtueState.Complete
else -> FtueState.Incomplete
}
} }
} }

View file

@ -0,0 +1,18 @@
/*
* 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.state
sealed interface InternalFtueState {
data object Unknown : InternalFtueState
data class Incomplete(
val nextStep: FtueStep,
) : InternalFtueState
data object Complete : InternalFtueState
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new backup password"</string>
<string name="screen_identity_confirmation_subtitle">"Confirm this device to set up secure messaging."</string>
<string name="screen_identity_confirmation_title">"Confirm it\'s you"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Use backup password"</string>
<string name="screen_identity_confirmed_title">"Device confirmed"</string>
<string name="screen_session_verification_enter_recovery_key">"Enter backup password"</string>
</resources>

View file

@ -0,0 +1,57 @@
/*
* 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.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() },
defaultFtueService = createDefaultFtueService(),
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

@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.state.DefaultFtueService import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.state.InternalFtueState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -26,8 +27,6 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.noop.NoopAnalyticsService import io.element.android.services.analytics.noop.NoopAnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
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
@ -69,9 +68,11 @@ class DefaultFtueServiceTest {
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
service.updateState() service.updateFtueStep()
service.state.test {
assertThat(service.state.value).isEqualTo(FtueState.Complete) assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
}
} }
@Test @Test
@ -90,9 +91,11 @@ class DefaultFtueServiceTest {
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
service.updateState() service.updateFtueStep()
service.state.test {
assertThat(service.state.value).isEqualTo(FtueState.Complete) assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
}
} }
@Test @Test
@ -109,35 +112,30 @@ class DefaultFtueServiceTest {
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
val steps = mutableListOf<FtueStep?>()
// Session verification service.ftueStepStateFlow.test {
steps.add(service.getNextStep(steps.lastOrNull())) assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified) // Session verification
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.SessionVerification))
// Notifications opt in sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
steps.add(service.getNextStep(steps.lastOrNull())) // User completes verification
permissionStateProvider.setPermissionGranted() service.onUserCompletedSessionVerification()
// Notifications opt in
// Entering PIN code assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.NotificationsOptIn))
steps.add(service.getNextStep(steps.lastOrNull())) permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) // Simulate event from NotificationsOptInNode.Callback.onNotificationsOptInFinished
service.updateFtueStep()
// Analytics opt in // Entering PIN code
steps.add(service.getNextStep(steps.lastOrNull())) assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.LockscreenSetup))
analyticsService.setDidAskUserConsent() lockScreenService.setIsPinSetup(true)
// Simulate event from LockScreenEntryPoint.Callback.onSetupDone()
// Final step (null) service.updateFtueStep()
steps.add(service.getNextStep(steps.lastOrNull())) // Analytics opt in
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
assertThat(steps).containsExactly( analyticsService.setDidAskUserConsent()
FtueStep.SessionVerification, // Final step
FtueStep.NotificationsOptIn, assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
FtueStep.LockscreenSetup, }
FtueStep.AnalyticsOptIn,
// Final state
null,
)
} }
@Test @Test
@ -158,10 +156,13 @@ class DefaultFtueServiceTest {
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) service.ftueStepStateFlow.test {
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
analyticsService.setDidAskUserConsent() // Analytics opt in
assertThat(service.getNextStep(null)).isNull() assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
analyticsService.setDidAskUserConsent()
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
}
} }
@Test @Test
@ -180,68 +181,30 @@ class DefaultFtueServiceTest {
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) service.ftueStepStateFlow.test {
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
analyticsService.setDidAskUserConsent() // Analytics opt in
assertThat(service.getNextStep(null)).isNull() assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
analyticsService.setDidAskUserConsent()
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
}
} }
@Test
fun `reset do the expected actions S`() = runTest {
val resetAnalyticsLambda = lambdaRecorder<Unit> { }
val analyticsService = FakeAnalyticsService(
resetLambda = resetAnalyticsLambda
)
val resetPermissionLambda = lambdaRecorder<String, Unit> { }
val permissionStateProvider = FakePermissionStateProvider(
resetPermissionLambda = resetPermissionLambda
)
val service = createDefaultFtueService(
sdkIntVersion = Build.VERSION_CODES.S,
permissionStateProvider = permissionStateProvider,
analyticsService = analyticsService,
)
service.reset()
resetAnalyticsLambda.assertions().isCalledOnce()
resetPermissionLambda.assertions().isNeverCalled()
}
@Test
fun `reset do the expected actions TIRAMISU`() = runTest {
val resetLambda = lambdaRecorder<Unit> { }
val analyticsService = FakeAnalyticsService(
resetLambda = resetLambda
)
val resetPermissionLambda = lambdaRecorder<String, Unit> { }
val permissionStateProvider = FakePermissionStateProvider(
resetPermissionLambda = resetPermissionLambda
)
val service = createDefaultFtueService(
sdkIntVersion = Build.VERSION_CODES.TIRAMISU,
permissionStateProvider = permissionStateProvider,
analyticsService = analyticsService,
)
service.reset()
resetLambda.assertions().isCalledOnce()
resetPermissionLambda.assertions().isCalledOnce()
.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

@ -9,18 +9,11 @@ package io.element.android.features.ftue.test
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.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class FakeFtueService( class FakeFtueService : FtueService {
private val resetLambda: () -> Unit = { lambdaError() },
) : FtueService {
override val state: MutableStateFlow<FtueState> = MutableStateFlow(FtueState.Unknown) override val state: MutableStateFlow<FtueState> = MutableStateFlow(FtueState.Unknown)
override suspend fun reset() {
resetLambda()
}
suspend fun emitState(newState: FtueState) { suspend fun emitState(newState: FtueState) {
state.emit(newState) state.emit(newState)
} }

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.
@ -55,16 +56,10 @@ dependencies {
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
implementation(projects.features.changeroommemberroles.api) implementation(projects.features.changeroommemberroles.api)
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)
@ -79,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

@ -269,7 +269,7 @@ private fun HomeScaffold(
.hazeSource(state = hazeState), .hazeSource(state = hazeState),
state = state.homeSpacesState, state = state.homeSpacesState,
onSpaceClick = { spaceId -> onSpaceClick = { spaceId ->
// TODO onRoomClick(spaceId)
} }
) )
} }

View file

@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Inject @Inject
@ -102,13 +103,44 @@ class RoomListDataSource(
} }
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) { private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) {
// Used to detect duplicates in the room list summaries - see comment below
data class CacheResult(val index: Int, val fromCache: Boolean)
val cachingResults = mutableMapOf<RoomId, MutableList<CacheResult>>()
val roomListRoomSummaries = diffCache.indices().mapNotNull { index -> val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
if (useCache) { if (useCache) {
diffCache.get(index) ?: buildAndCacheItem(roomSummaries, index) diffCache.get(index)?.let { cachedItem ->
// Add the cached item to the caching results
val pairs = cachingResults.getOrDefault(cachedItem.roomId, mutableListOf())
pairs.add(CacheResult(index, fromCache = true))
cachingResults[cachedItem.roomId] = pairs
cachedItem
} ?: run {
roomSummaries.getOrNull(index)?.roomId?.let {
// Add the non-cached item to the caching results
val pairs = cachingResults.getOrDefault(it, mutableListOf())
pairs.add(CacheResult(index, fromCache = false))
cachingResults[it] = pairs
}
buildAndCacheItem(roomSummaries, index)
}
} else { } else {
roomSummaries.getOrNull(index)?.roomId?.let {
// Add the non-cached item to the caching results
val pairs = cachingResults.getOrDefault(it, mutableListOf())
pairs.add(CacheResult(index, fromCache = false))
cachingResults[it] = pairs
}
buildAndCacheItem(roomSummaries, index) buildAndCacheItem(roomSummaries, index)
} }
} }
// TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed
val duplicates = cachingResults.filter { (_, operations) -> operations.size > 1 }
if (duplicates.isNotEmpty()) {
Timber.e("Found duplicates in room summaries after an UI update: $duplicates. This could be a race condition/caching issue of some kind")
}
_allRooms.emit(roomListRoomSummaries.toImmutableList()) _allRooms.emit(roomListRoomSummaries.toImmutableList())
} }

View file

@ -36,7 +36,6 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -44,6 +43,7 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.battery.BatteryOptimizationState import io.element.android.libraries.push.api.battery.BatteryOptimizationState
@ -101,12 +101,7 @@ class RoomListPresenter(
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) } var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
// Avatar indicator // Avatar indicator
val hideInvitesAvatar by remember { val hideInvitesAvatar by client.rememberHideInvitesAvatar()
client
.mediaPreviewService()
.mediaPreviewConfigFlow
.mapState { config -> config.hideInviteAvatar }
}.collectAsState()
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) } val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }

View file

@ -13,10 +13,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.seenSpaceIds
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentSet import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -28,15 +27,10 @@ class HomeSpacesPresenter(
) : Presenter<HomeSpacesState> { ) : Presenter<HomeSpacesState> {
@Composable @Composable
override fun present(): HomeSpacesState { override fun present(): HomeSpacesState {
val hideInvitesAvatar by remember { val hideInvitesAvatar by client.rememberHideInvitesAvatar()
client val spaceRooms by client.spaceService.spaceRoomsFlow.collectAsState(emptyList())
.mediaPreviewService()
.mediaPreviewConfigFlow
.mapState { config -> config.hideInviteAvatar }
}.collectAsState()
val spaceRooms by client.spaceService.spaceRooms.collectAsState(emptyList())
val seenSpaceInvites by remember { val seenSpaceInvites by remember {
seenInvitesStore.seenSpaceIds().map { it.toPersistentSet() } seenInvitesStore.seenRoomIds().map { it.toPersistentSet() }
}.collectAsState(persistentSetOf()) }.collectAsState(persistentSetOf())
fun handleEvents(event: HomeSpacesEvents) { fun handleEvents(event: HomeSpacesEvents) {

View file

@ -7,14 +7,14 @@
package io.element.android.features.home.impl.spaces package io.element.android.features.home.impl.spaces
import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
data class HomeSpacesState( data class HomeSpacesState(
val space: CurrentSpace, val space: CurrentSpace,
val spaceRooms: List<SpaceRoom>, val spaceRooms: List<SpaceRoom>,
val seenSpaceInvites: ImmutableSet<SpaceId>, val seenSpaceInvites: ImmutableSet<RoomId>,
val hideInvitesAvatar: Boolean, val hideInvitesAvatar: Boolean,
val eventSink: (HomeSpacesEvents) -> Unit, val eventSink: (HomeSpacesEvents) -> Unit,
) )

View file

@ -8,8 +8,9 @@
package io.element.android.features.home.impl.spaces package io.element.android.features.home.impl.spaces
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> { open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
@ -18,12 +19,12 @@ open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
aHomeSpacesState( aHomeSpacesState(
spaceRooms = SpaceRoomProvider().values.toList(), spaceRooms = SpaceRoomProvider().values.toList(),
seenSpaceInvites = setOf( seenSpaceInvites = setOf(
SpaceId("!spaceId3:example.com"), RoomId("!spaceId3:example.com"),
), ),
), ),
aHomeSpacesState( aHomeSpacesState(
space = CurrentSpace.Space( space = CurrentSpace.Space(
spaceRoom = aSpaceRooms(spaceId = SpaceId("!mySpace:example.com")) spaceRoom = aSpaceRoom(roomId = RoomId("!mySpace:example.com"))
), ),
spaceRooms = aListOfSpaceRooms(), spaceRooms = aListOfSpaceRooms(),
), ),
@ -33,7 +34,7 @@ open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
internal fun aHomeSpacesState( internal fun aHomeSpacesState(
space: CurrentSpace = CurrentSpace.Root, space: CurrentSpace = CurrentSpace.Root,
spaceRooms: List<SpaceRoom> = aListOfSpaceRooms(), spaceRooms: List<SpaceRoom> = aListOfSpaceRooms(),
seenSpaceInvites: Set<SpaceId> = emptySet(), seenSpaceInvites: Set<RoomId> = emptySet(),
hideInvitesAvatar: Boolean = false, hideInvitesAvatar: Boolean = false,
eventSink: (HomeSpacesEvents) -> Unit = {}, eventSink: (HomeSpacesEvents) -> Unit = {},
) = HomeSpacesState( ) = HomeSpacesState(
@ -46,8 +47,8 @@ internal fun aHomeSpacesState(
fun aListOfSpaceRooms(): List<SpaceRoom> { fun aListOfSpaceRooms(): List<SpaceRoom> {
return listOf( return listOf(
aSpaceRooms(spaceId = SpaceId("!spaceId0:example.com")), aSpaceRoom(roomId = RoomId("!spaceId0:example.com")),
aSpaceRooms(spaceId = SpaceId("!spaceId1:example.com")), aSpaceRoom(roomId = RoomId("!spaceId1:example.com")),
aSpaceRooms(spaceId = SpaceId("!spaceId2:example.com")), aSpaceRoom(roomId = RoomId("!spaceId2:example.com")),
) )
} }

View file

@ -14,17 +14,18 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView
import io.element.android.libraries.matrix.ui.components.SpaceHeaderView import io.element.android.libraries.matrix.ui.components.SpaceHeaderView
import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun HomeSpacesView( fun HomeSpacesView(
state: HomeSpacesState, state: HomeSpacesState,
onSpaceClick: (SpaceId) -> Unit, onSpaceClick: (RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn(modifier) { LazyColumn(modifier) {
@ -51,15 +52,18 @@ fun HomeSpacesView(
) )
} }
} }
state.spaceRooms.forEach { state.spaceRooms.forEach { spaceRoom ->
item(it.spaceId) { item(spaceRoom.roomId) {
val isInvitation = it.state == CurrentUserMembership.INVITED val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
HomeSpaceItemView( SpaceRoomItemView(
spaceRoom = it, spaceRoom = spaceRoom,
showUnreadIndicator = isInvitation && it.spaceId !in state.seenSpaceInvites, showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
hideAvatars = isInvitation && state.hideInvitesAvatar, hideAvatars = isInvitation && state.hideInvitesAvatar,
onClick = { onClick = {
onSpaceClick(it.spaceId) onSpaceClick(spaceRoom.roomId)
},
onLongClick = {
// TODO
} }
) )
} }

View file

@ -8,77 +8,44 @@
package io.element.android.features.home.impl.spaces package io.element.android.features.home.impl.spaces
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.previewutils.room.aSpaceRoom
class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> { class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
override val values: Sequence<SpaceRoom> = sequenceOf( override val values: Sequence<SpaceRoom> = sequenceOf(
aSpaceRooms(), aSpaceRoom(),
aSpaceRooms( aSpaceRoom(
numJoinedMembers = 5, numJoinedMembers = 5,
childrenCount = 10, childrenCount = 10,
worldReadable = true, worldReadable = true,
spaceId = SpaceId("!spaceId0:example.com"), roomId = RoomId("!spaceId0:example.com"),
), ),
aSpaceRooms( aSpaceRoom(
numJoinedMembers = 5, numJoinedMembers = 5,
childrenCount = 10, childrenCount = 10,
worldReadable = true, worldReadable = true,
avatarUrl = "anUrl", avatarUrl = "anUrl",
spaceId = SpaceId("!spaceId1:example.com"), roomId = RoomId("!spaceId1:example.com"),
), ),
aSpaceRooms( aSpaceRoom(
name = null, name = null,
numJoinedMembers = 5, numJoinedMembers = 5,
childrenCount = 10, childrenCount = 10,
worldReadable = true, worldReadable = true,
avatarUrl = "anUrl", avatarUrl = "anUrl",
spaceId = SpaceId("!spaceId2:example.com"), roomId = RoomId("!spaceId2:example.com"),
state = CurrentUserMembership.INVITED, state = CurrentUserMembership.INVITED,
), ),
aSpaceRooms( aSpaceRoom(
name = null, name = null,
numJoinedMembers = 5, numJoinedMembers = 5,
childrenCount = 10, childrenCount = 10,
worldReadable = true, worldReadable = true,
avatarUrl = "anUrl", avatarUrl = "anUrl",
spaceId = SpaceId("!spaceId3:example.com"), roomId = RoomId("!spaceId3:example.com"),
state = CurrentUserMembership.INVITED, state = CurrentUserMembership.INVITED,
), ),
) )
} }
fun aSpaceRooms(
name: String? = "Space name",
avatarUrl: String? = null,
canonicalAlias: RoomAlias? = null,
childrenCount: Int = 0,
guestCanJoin: Boolean = false,
heroes: List<MatrixUser> = emptyList(),
joinRule: JoinRule? = null,
numJoinedMembers: Int = 0,
spaceId: SpaceId = SpaceId("!spaceId:example.com"),
roomType: RoomType = RoomType.Space,
state: CurrentUserMembership? = null,
topic: String? = null,
worldReadable: Boolean = false,
) = SpaceRoom(
name = name,
avatarUrl = avatarUrl,
canonicalAlias = canonicalAlias,
childrenCount = childrenCount,
guestCanJoin = guestCanJoin,
heroes = heroes,
joinRule = joinRule,
numJoinedMembers = numJoinedMembers,
spaceId = spaceId,
roomType = roomType,
state = state,
topic = topic,
worldReadable = worldReadable
)

View file

@ -13,6 +13,7 @@
<string name="full_screen_intent_banner_message">"Er mwyn sicrhau fyddwch chi ddim yn colli galwad bwysig, newidiwch eich gosodiadau i ganiatáu hysbysiadau sgrin lawn pan fydd eich ffôn wedi\'i gloi."</string> <string name="full_screen_intent_banner_message">"Er mwyn sicrhau fyddwch chi ddim yn colli galwad bwysig, newidiwch eich gosodiadau i ganiatáu hysbysiadau sgrin lawn pan fydd eich ffôn wedi\'i gloi."</string>
<string name="full_screen_intent_banner_title">"Gwella profiad eich galwadau"</string> <string name="full_screen_intent_banner_title">"Gwella profiad eich galwadau"</string>
<string name="screen_home_tab_chats">"Sgyrsiau"</string> <string name="screen_home_tab_chats">"Sgyrsiau"</string>
<string name="screen_home_tab_spaces">"Gofodau"</string>
<string name="screen_invites_decline_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?"</string> <string name="screen_invites_decline_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Gwrthod y gwahoddiad"</string> <string name="screen_invites_decline_chat_title">"Gwrthod y gwahoddiad"</string>
<string name="screen_invites_decline_direct_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?"</string>
@ -32,6 +33,7 @@ Am y tro, gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"</strin
<string name="screen_roomlist_filter_invites">"Gwahoddiadau"</string> <string name="screen_roomlist_filter_invites">"Gwahoddiadau"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Does gennych chi ddim gwahoddiadau yn aros."</string> <string name="screen_roomlist_filter_invites_empty_state_title">"Does gennych chi ddim gwahoddiadau yn aros."</string>
<string name="screen_roomlist_filter_low_priority">"Blaenoriaeth Isel"</string> <string name="screen_roomlist_filter_low_priority">"Blaenoriaeth Isel"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Does gennych chi ddim sgyrsiau blaenoriaeth isel eto"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"</string> <string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Does gennych chi ddim sgyrsiau ar gyfer y dewis hwn"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"Does gennych chi ddim sgyrsiau ar gyfer y dewis hwn"</string>
<string name="screen_roomlist_filter_people">"Pobl"</string> <string name="screen_roomlist_filter_people">"Pobl"</string>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Restore your account security and message history with a backup password if you have lost all your existing devices."</string>
<string name="banner_set_up_recovery_submit">"Set up backup"</string>
<string name="banner_set_up_recovery_title">"Set up backup to protect your account"</string>
<string name="confirm_recovery_key_banner_message">"Confirm your backup password to maintain access to your message backup and message history."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Enter your backup password"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Forgot your backup password?"</string>
<string name="confirm_recovery_key_banner_title">"Your message backup is out of sync"</string>
<string name="session_verification_banner_message">"Looks like you\'re using a new device. Confirm it with another connected device to access your encrypted messages."</string>
</resources>

View file

@ -33,6 +33,7 @@ Toistaiseksi voit poistaa suodattimien valinnan, jotta näet muut keskustelut."<
<string name="screen_roomlist_filter_invites">"Kutsut"</string> <string name="screen_roomlist_filter_invites">"Kutsut"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Sinulla ei ole yhtään odottavaa kutsua."</string> <string name="screen_roomlist_filter_invites_empty_state_title">"Sinulla ei ole yhtään odottavaa kutsua."</string>
<string name="screen_roomlist_filter_low_priority">"Matala prioriteetti"</string> <string name="screen_roomlist_filter_low_priority">"Matala prioriteetti"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Sinulla ei ole vielä yhtään matalan prioriteetin keskustelua"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi."</string> <string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string>
<string name="screen_roomlist_filter_people">"Ihmiset"</string> <string name="screen_roomlist_filter_people">"Ihmiset"</string>

View file

@ -13,6 +13,7 @@
<string name="full_screen_intent_banner_message">"For å sikre at du aldri går glipp av en viktig samtale, må du endre innstillingene dine for å tillate fullskjermvarsler når telefonen er låst."</string> <string name="full_screen_intent_banner_message">"For å sikre at du aldri går glipp av en viktig samtale, må du endre innstillingene dine for å tillate fullskjermvarsler når telefonen er låst."</string>
<string name="full_screen_intent_banner_title">"Forbedre samtaleopplevelsen din"</string> <string name="full_screen_intent_banner_title">"Forbedre samtaleopplevelsen din"</string>
<string name="screen_home_tab_chats">"Chatter"</string> <string name="screen_home_tab_chats">"Chatter"</string>
<string name="screen_home_tab_spaces">"Områder"</string>
<string name="screen_invites_decline_chat_message">"Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?"</string> <string name="screen_invites_decline_chat_message">"Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Avvis invitasjon"</string> <string name="screen_invites_decline_chat_title">"Avvis invitasjon"</string>
<string name="screen_invites_decline_direct_chat_message">"Er du sikker på at du vil avslå denne private chatten med %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Er du sikker på at du vil avslå denne private chatten med %1$s?"</string>

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

@ -33,6 +33,7 @@
<string name="screen_roomlist_filter_invites">"邀請"</string> <string name="screen_roomlist_filter_invites">"邀請"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"您沒有任何擱置中的邀請。"</string> <string name="screen_roomlist_filter_invites_empty_state_title">"您沒有任何擱置中的邀請。"</string>
<string name="screen_roomlist_filter_low_priority">"低優先度"</string> <string name="screen_roomlist_filter_low_priority">"低優先度"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"您尚無任何低優先程度聊天"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"您可以取消選取篩選條件以檢視其他聊天"</string> <string name="screen_roomlist_filter_mixed_empty_state_subtitle">"您可以取消選取篩選條件以檢視其他聊天"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string>
<string name="screen_roomlist_filter_people">"夥伴"</string> <string name="screen_roomlist_filter_people">"夥伴"</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

@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@ -36,3 +37,11 @@ fun RoomInfo.toInviteData(): InviteData {
isDm = isDm, isDm = isDm,
) )
} }
fun SpaceRoom.toInviteData(): InviteData {
return InviteData(
roomId = roomId,
roomName = name ?: roomId.value,
isDm = false,
)
}

View file

@ -8,10 +8,7 @@
package io.element.android.features.invite.api package io.element.android.features.invite.api
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.SpaceId
import io.element.android.libraries.matrix.api.core.toSpaceId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface SeenInvitesStore { interface SeenInvitesStore {
/** /**
@ -38,9 +35,3 @@ interface SeenInvitesStore {
*/ */
suspend fun clear() suspend fun clear()
} }
fun SeenInvitesStore.seenSpaceIds(): Flow<Set<SpaceId>> {
return seenRoomIds().map { roomIds ->
roomIds.map { it.toSpaceId() }.toSet()
}
}

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,9 @@ 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) testImplementation(projects.libraries.previewutils)
} }

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