Merge branch 'release/25.09.2' into main
This commit is contained in:
commit
ca686c9e54
884 changed files with 10425 additions and 4456 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -62,6 +62,7 @@ captures/
|
|||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/copilot
|
||||
.idea/copilot.*
|
||||
.idea/inspectionProfiles
|
||||
# Shelved changes in the IDE
|
||||
.idea/shelf
|
||||
|
|
|
|||
58
CHANGES.md
58
CHANGES.md
|
|
@ -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
|
||||
=============================
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import extension.koverDependencies
|
|||
import extension.locales
|
||||
import extension.setupDependencyInjection
|
||||
import extension.setupKover
|
||||
import extension.testCommonDependencies
|
||||
import java.util.Locale
|
||||
|
||||
plugins {
|
||||
|
|
@ -290,15 +291,9 @@ dependencies {
|
|||
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
koverDependencies()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<locale android:name="el"/>
|
||||
<locale android:name="en"/>
|
||||
<locale android:name="en_US"/>
|
||||
<locale android:name="eo"/>
|
||||
<locale android:name="es"/>
|
||||
<locale android:name="et"/>
|
||||
<locale android:name="eu"/>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import extension.allFeaturesApi
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
|
|
@ -42,18 +43,12 @@ dependencies {
|
|||
|
||||
implementation(projects.features.ftue.api)
|
||||
implementation(projects.features.share.api)
|
||||
implementation(projects.features.viewfolder.api)
|
||||
|
||||
implementation(projects.services.apperror.impl)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.features.login.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.oidc.test)
|
||||
|
|
@ -61,11 +56,8 @@ dependencies {
|
|||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.libraries.pushproviders.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.features.rageshake.test)
|
||||
testImplementation(projects.services.appnavstate.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
testImplementation(libs.test.appyx.junit)
|
||||
testImplementation(libs.test.arch.core)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.FtueState
|
||||
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.NetworkStatus
|
||||
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.securebackup.api.SecureBackupEntryPoint
|
||||
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.userprofile.api.UserProfileEntryPoint
|
||||
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
|
||||
|
|
@ -118,7 +118,6 @@ class LoggedInFlowNode(
|
|||
private val shareEntryPoint: ShareEntryPoint,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val sendingQueue: SendQueues,
|
||||
private val logoutEntryPoint: LogoutEntryPoint,
|
||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
|
||||
private val sessionEnterpriseService: SessionEnterpriseService,
|
||||
|
|
@ -276,9 +275,6 @@ class LoggedInFlowNode(
|
|||
@Parcelize
|
||||
data class IncomingShare(val intent: Intent) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
|
||||
}
|
||||
|
|
@ -323,10 +319,6 @@ class LoggedInFlowNode(
|
|||
override fun onReportBugClick() {
|
||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||
}
|
||||
|
||||
override fun onLogoutForNativeSlidingSyncMigrationNeeded() {
|
||||
backstack.push(NavTarget.LogoutForNativeSlidingSyncMigrationNeeded)
|
||||
}
|
||||
}
|
||||
homeEntryPoint
|
||||
.nodeBuilder(this, buildContext)
|
||||
|
|
@ -334,7 +326,7 @@ class LoggedInFlowNode(
|
|||
.build()
|
||||
}
|
||||
is NavTarget.Room -> {
|
||||
val callback = object : JoinedRoomLoadedFlowNode.Callback {
|
||||
val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback {
|
||||
override fun onOpenRoom(roomId: RoomId, serverNames: List<String>) {
|
||||
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames))
|
||||
}
|
||||
|
|
@ -373,6 +365,11 @@ class LoggedInFlowNode(
|
|||
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(
|
||||
roomIdOrAlias = navTarget.roomIdOrAlias,
|
||||
roomDescription = Optional.ofNullable(navTarget.roomDescription),
|
||||
|
|
@ -380,7 +377,7 @@ class LoggedInFlowNode(
|
|||
trigger = Optional.ofNullable(navTarget.trigger),
|
||||
initialElement = navTarget.initialElement
|
||||
)
|
||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback))
|
||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, joinedRoomCallback, spaceCallback))
|
||||
}
|
||||
is NavTarget.UserProfile -> {
|
||||
val callback = object : UserProfileEntryPoint.Callback {
|
||||
|
|
@ -448,8 +445,7 @@ class LoggedInFlowNode(
|
|||
.build()
|
||||
}
|
||||
NavTarget.Ftue -> {
|
||||
ftueEntryPoint.nodeBuilder(this, buildContext)
|
||||
.build()
|
||||
ftueEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
NavTarget.RoomDirectorySearch -> {
|
||||
roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
|
||||
|
|
@ -480,17 +476,6 @@ class LoggedInFlowNode(
|
|||
.params(ShareEntryPoint.Params(intent = navTarget.intent))
|
||||
.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 -> {
|
||||
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(IncomingVerificationEntryPoint.Params(navTarget.data))
|
||||
|
|
|
|||
|
|
@ -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.reporter.BugReporter
|
||||
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.BaseFlowNode
|
||||
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.deeplink.api.DeeplinkData
|
||||
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.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||
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.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
|
@ -65,13 +64,12 @@ import timber.log.Timber
|
|||
class RootFlowNode(
|
||||
@Assisted val buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val sessionStore: SessionStore,
|
||||
private val accountProviderAccessControl: AccountProviderAccessControl,
|
||||
private val navStateFlowFactory: RootNavStateFlowFactory,
|
||||
private val matrixSessionCache: MatrixSessionCache,
|
||||
private val presenter: RootPresenter,
|
||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||
private val viewFolderEntryPoint: ViewFolderEntryPoint,
|
||||
private val signedOutEntryPoint: SignedOutEntryPoint,
|
||||
private val intentResolver: IntentResolver,
|
||||
private val oidcActionFlow: OidcActionFlow,
|
||||
|
|
@ -154,7 +152,7 @@ class RootFlowNode(
|
|||
onSuccess: (SessionId) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
val latestSessionId = authenticationService.getLatestSessionId()
|
||||
val latestSessionId = sessionStore.getLatestSessionId()
|
||||
if (latestSessionId == null) {
|
||||
onFailure()
|
||||
return
|
||||
|
|
@ -200,11 +198,6 @@ class RootFlowNode(
|
|||
|
||||
@Parcelize
|
||||
data object BugReport : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class ViewLogs(
|
||||
val rootPath: String,
|
||||
) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
@ -244,31 +237,12 @@ class RootFlowNode(
|
|||
NavTarget.SplashScreen -> splashNode(buildContext)
|
||||
NavTarget.BugReport -> {
|
||||
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() {
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
val params = ViewFolderEntryPoint.Params(
|
||||
rootPath = navTarget.rootPath,
|
||||
)
|
||||
viewFolderEntryPoint
|
||||
bugReportEntryPoint
|
||||
.nodeBuilder(this, buildContext)
|
||||
.params(params)
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
|
|
@ -294,7 +268,7 @@ class RootFlowNode(
|
|||
|
||||
private suspend fun onLoginLink(params: LoginParams) {
|
||||
// Is there a session already?
|
||||
val latestSessionId = authenticationService.getLatestSessionId()
|
||||
val latestSessionId = sessionStore.getLatestSessionId()
|
||||
if (latestSessionId == null) {
|
||||
// No session, open login
|
||||
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
|
||||
|
|
@ -311,7 +285,7 @@ class RootFlowNode(
|
|||
|
||||
private suspend fun onIncomingShare(intent: Intent) {
|
||||
// Is there a session already?
|
||||
val latestSessionId = authenticationService.getLatestSessionId()
|
||||
val latestSessionId = sessionStore.getLatestSessionId()
|
||||
if (latestSessionId == null) {
|
||||
// No session, open login
|
||||
switchToNotLoggedInFlow(null)
|
||||
|
|
@ -368,3 +342,5 @@ class RootFlowNode(
|
|||
.attachSession()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@ package io.element.android.appnav.di
|
|||
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
|
||||
interface RoomComponentFactory {
|
||||
fun interface RoomComponentFactory {
|
||||
fun create(room: JoinedRoom): Any
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@ import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
|
|||
import io.element.android.appnav.room.joined.LoadingRoomNodeView
|
||||
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
|
||||
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.space.api.SpaceEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
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.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
|
|
@ -72,6 +73,7 @@ class RoomFlowNode(
|
|||
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
|
||||
private val syncService: SyncService,
|
||||
private val membershipObserver: RoomMembershipObserver,
|
||||
private val spaceEntryPoint: SpaceEntryPoint,
|
||||
) : BaseFlowNode<RoomFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Loading,
|
||||
|
|
@ -106,6 +108,9 @@ class RoomFlowNode(
|
|||
|
||||
@Parcelize
|
||||
data class JoinedRoom(val roomId: RoomId) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class JoinedSpace(val spaceId: RoomId) : NavTarget
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
|
|
@ -143,40 +148,28 @@ class RoomFlowNode(
|
|||
.withPreviousValue()
|
||||
combine(currentMembershipFlow, isSpaceFlow) { (previousMembership, membership), isSpace ->
|
||||
Timber.d("Room membership: $membership")
|
||||
when (membership) {
|
||||
CurrentUserMembership.JOINED -> {
|
||||
if (isSpace) {
|
||||
// It should not happen, but probably due to an issue in the sliding sync,
|
||||
// we can have a space here in case the space has just been joined.
|
||||
// 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))
|
||||
}
|
||||
if (membership == CurrentUserMembership.JOINED) {
|
||||
if (isSpace) {
|
||||
backstack.newRoot(NavTarget.JoinedSpace(spaceId = roomId))
|
||||
} else {
|
||||
backstack.newRoot(NavTarget.JoinedRoom(roomId))
|
||||
}
|
||||
else -> {
|
||||
if (membership == CurrentUserMembership.LEFT && previousMembership == CurrentUserMembership.JOINED) {
|
||||
// The user left the room in this device, remove the room from the backstack
|
||||
if (!membershipUpdateFlow.first().isUserInRoom) {
|
||||
navigateUp()
|
||||
}
|
||||
} else {
|
||||
// Was invited or the room is not known, display the join room screen
|
||||
backstack.newRoot(
|
||||
NavTarget.JoinRoom(
|
||||
roomId = roomId,
|
||||
serverNames = serverNames,
|
||||
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite,
|
||||
)
|
||||
} else {
|
||||
val leavingFromCurrentDevice =
|
||||
membership == CurrentUserMembership.LEFT &&
|
||||
previousMembership == CurrentUserMembership.JOINED &&
|
||||
membershipUpdateFlow.replayCache.lastOrNull()?.isUserInRoom == false
|
||||
|
||||
if (leavingFromCurrentDevice) {
|
||||
navigateUp()
|
||||
} else {
|
||||
backstack.newRoot(
|
||||
NavTarget.JoinRoom(
|
||||
roomId = roomId,
|
||||
serverNames = serverNames,
|
||||
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}.launchIn(lifecycleScope)
|
||||
|
|
@ -194,7 +187,7 @@ class RoomFlowNode(
|
|||
)
|
||||
}
|
||||
}
|
||||
val params = RoomAliasResolverEntryPoint.Params(navTarget.roomAlias)
|
||||
val params = Params(navTarget.roomAlias)
|
||||
roomAliasResolverEntryPoint.nodeBuilder(this, buildContext)
|
||||
.callback(callback)
|
||||
.params(params)
|
||||
|
|
@ -218,6 +211,13 @@ class RoomFlowNode(
|
|||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import com.bumble.appyx.core.state.SavedStateMap
|
|||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.appnav.di.MatrixSessionCache
|
||||
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.preferences.api.store.SessionPreferencesStoreFactory
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
|
@ -28,7 +28,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFact
|
|||
*/
|
||||
@Inject
|
||||
class RootNavStateFlowFactory(
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val sessionStore: SessionStore,
|
||||
private val cacheService: CacheService,
|
||||
private val matrixSessionCache: MatrixSessionCache,
|
||||
private val imageLoaderHolder: ImageLoaderHolder,
|
||||
|
|
@ -39,7 +39,7 @@ class RootNavStateFlowFactory(
|
|||
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
|
||||
return combine(
|
||||
cacheIndexFlow(savedStateMap),
|
||||
authenticationService.loggedInStateFlow(),
|
||||
sessionStore.loggedInStateFlow(),
|
||||
) { cacheIndex, loggedInState ->
|
||||
RootNavState(
|
||||
cacheIndex = cacheIndex,
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_migrate_to_native_sliding_sync_action">"Sair & 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_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>
|
||||
|
|
|
|||
|
|
@ -501,22 +501,16 @@ class LoggedInPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - CheckSlidingSyncProxyAvailability forces the sliding sync migration under the right circumstances`() = runTest {
|
||||
// The migration will be forced if:
|
||||
// - The user is not using the native sliding sync
|
||||
// - The sliding sync proxy is no longer supported
|
||||
// - The native sliding sync is supported
|
||||
// The migration will be forced if the user is not using the native sliding sync
|
||||
val matrixClient = FakeMatrixClient(
|
||||
currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
|
||||
availableSlidingSyncVersionsLambda = { Result.success(listOf(SlidingSyncVersion.Native)) },
|
||||
)
|
||||
createLoggedInPresenter(
|
||||
matrixClient = matrixClient,
|
||||
).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
|
||||
|
||||
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
|
||||
|
||||
assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ allprojects {
|
|||
// Fix compilation warning for annotations
|
||||
// See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
|
||||
freeCompilerArgs.add("-Xannotation-default-target=first-only")
|
||||
// Opt-in to context receivers
|
||||
freeCompilerArgs.add("-Xcontext-parameters")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
fastlane/metadata/android/en-US/changelogs/202509020.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/202509020.txt
Normal 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
|
||||
|
|
@ -9,4 +9,4 @@ package io.element.android.features.analytics.api
|
|||
|
||||
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
|
||||
|
||||
interface AnalyticsEntryPoint : SimpleFeatureEntryPoint
|
||||
fun interface AnalyticsEntryPoint : SimpleFeatureEntryPoint
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
|
|
@ -30,13 +31,7 @@ dependencies {
|
|||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.browser)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.mockk)
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
|
|
@ -22,8 +23,5 @@ dependencies {
|
|||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testCommonDependencies(libs)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ interface ElementCallEntryPoint {
|
|||
* @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 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 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?,
|
||||
avatarUrl: String?,
|
||||
timestamp: Long,
|
||||
expirationTimestamp: Long,
|
||||
notificationChannelId: String,
|
||||
textContent: String?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import extension.buildConfigFieldStr
|
||||
import extension.readLocalProperty
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
|
|
@ -87,12 +88,7 @@ dependencies {
|
|||
implementation(libs.element.call.embedded)
|
||||
api(projects.features.call.api)
|
||||
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.mockk)
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.features.call.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
|
|
@ -100,7 +96,5 @@ dependencies {
|
|||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.appnavstate.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class DefaultElementCallEntryPoint(
|
|||
senderName: String?,
|
||||
avatarUrl: String?,
|
||||
timestamp: Long,
|
||||
expirationTimestamp: Long,
|
||||
notificationChannelId: String,
|
||||
textContent: String?,
|
||||
) {
|
||||
|
|
@ -55,6 +56,7 @@ class DefaultElementCallEntryPoint(
|
|||
senderName = senderName,
|
||||
avatarUrl = avatarUrl,
|
||||
timestamp = timestamp,
|
||||
expirationTimestamp = expirationTimestamp,
|
||||
notificationChannelId = notificationChannelId,
|
||||
textContent = textContent,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,5 +41,8 @@ data class WidgetMessage(
|
|||
|
||||
@SerialName("send_event")
|
||||
SendEvent,
|
||||
|
||||
@SerialName("content_loaded")
|
||||
ContentLoaded,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,4 +26,6 @@ data class CallNotificationData(
|
|||
val notificationChannelId: String,
|
||||
val timestamp: Long,
|
||||
val textContent: String?,
|
||||
// Expiration timestamp in millis since epoch
|
||||
val expirationTimestamp: Long,
|
||||
) : Parcelable
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class RingingCallNotificationCreator(
|
|||
roomAvatarUrl: String?,
|
||||
notificationChannelId: String,
|
||||
timestamp: Long,
|
||||
expirationTimestamp: Long,
|
||||
textContent: String?,
|
||||
): Notification? {
|
||||
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
|
||||
|
|
@ -88,6 +89,7 @@ class RingingCallNotificationCreator(
|
|||
notificationChannelId = notificationChannelId,
|
||||
timestamp = timestamp,
|
||||
textContent = textContent,
|
||||
expirationTimestamp = expirationTimestamp,
|
||||
)
|
||||
|
||||
val declineIntent = PendingIntentCompat.getBroadcast(
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class CallScreenPresenter(
|
|||
val urlState = remember { mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized) }
|
||||
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(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 webViewError by remember { mutableStateOf<String?>(null) }
|
||||
val languageTag = languageTagProvider.provideLanguageTag()
|
||||
|
|
@ -139,8 +139,8 @@ class CallScreenPresenter(
|
|||
if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) {
|
||||
if (parsedMessage.action == WidgetMessage.Action.Close) {
|
||||
close(callWidgetDriver.value, navigator)
|
||||
} else if (parsedMessage.action == WidgetMessage.Action.Join) {
|
||||
isJoinedCall = true
|
||||
} else if (parsedMessage.action == WidgetMessage.Action.ContentLoaded) {
|
||||
isWidgetLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,8 +151,8 @@ class CallScreenPresenter(
|
|||
// Wait for the call to be joined, if it takes too long, we display an error
|
||||
delay(10.seconds)
|
||||
|
||||
if (!isJoinedCall) {
|
||||
Timber.w("The call took too long to be joined. Displaying an error before exiting.")
|
||||
if (!isWidgetLoaded) {
|
||||
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
|
||||
webViewError = ""
|
||||
|
|
@ -165,10 +165,10 @@ class CallScreenPresenter(
|
|||
is CallScreenEvents.Hangup -> {
|
||||
val widgetId = callWidgetDriver.value?.id
|
||||
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.
|
||||
sendHangupMessage(widgetId, interceptor)
|
||||
isJoinedCall = false
|
||||
isWidgetLoaded = false
|
||||
|
||||
coroutineScope.launch {
|
||||
// Wait for a couple of seconds to receive the hangup message
|
||||
|
|
@ -198,7 +198,7 @@ class CallScreenPresenter(
|
|||
urlState = urlState.value,
|
||||
webViewError = webViewError,
|
||||
userAgent = userAgent,
|
||||
isCallActive = isJoinedCall,
|
||||
isCallActive = isWidgetLoaded,
|
||||
isInWidgetMode = isInWidgetMode,
|
||||
eventSink = { handleEvents(it) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ internal fun IncomingCallScreenPreview() = ElementPreview {
|
|||
notificationChannelId = "incoming_call",
|
||||
timestamp = 0L,
|
||||
textContent = null,
|
||||
expirationTimestamp = 1000L,
|
||||
),
|
||||
onAnswer = {},
|
||||
onCancel = {},
|
||||
|
|
|
|||
|
|
@ -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.OnMissedCallNotificationHandler
|
||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -53,7 +54,7 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Manages the active call state.
|
||||
|
|
@ -98,6 +99,7 @@ class DefaultActiveCallManager(
|
|||
private val defaultCurrentCallService: DefaultCurrentCallService,
|
||||
private val appForegroundStateService: AppForegroundStateService,
|
||||
private val imageLoaderHolder: ImageLoaderHolder,
|
||||
private val systemClock: SystemClock,
|
||||
) : ActiveCallManager {
|
||||
private val tag = "DefaultActiveCallManager"
|
||||
private var timedOutCallJob: Job? = null
|
||||
|
|
@ -118,8 +120,20 @@ class DefaultActiveCallManager(
|
|||
|
||||
override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
|
||||
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)
|
||||
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) {
|
||||
displayMissedCallNotification(notificationData)
|
||||
Timber.tag(tag).w("Already have an active call, ignoring incoming call: $notificationData")
|
||||
|
|
@ -138,14 +152,14 @@ class DefaultActiveCallManager(
|
|||
showIncomingCallNotification(notificationData)
|
||||
|
||||
// Wait for the ringing call to time out
|
||||
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
|
||||
delay(timeMillis = ringDuration)
|
||||
incomingCallTimedOut(displayMissedCallNotification = true)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 {
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
||||
Timber.tag(tag).d("Hung up call: $callType")
|
||||
if (currentActiveCall.callState is CallState.Ringing) {
|
||||
// Decline the call
|
||||
val notificationData = currentActiveCall.callState.notificationData
|
||||
matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull()
|
||||
?.getRoom(notificationData.roomId)
|
||||
?.declineCall(notificationData.eventId)
|
||||
}
|
||||
|
||||
cancelIncomingCallNotification()
|
||||
if (activeWakeLock?.isHeld == true) {
|
||||
|
|
@ -226,6 +250,7 @@ class DefaultActiveCallManager(
|
|||
notificationChannelId = notificationData.notificationChannelId,
|
||||
timestamp = notificationData.timestamp,
|
||||
textContent = notificationData.textContent,
|
||||
expirationTimestamp = notificationData.expirationTimestamp,
|
||||
) ?: return
|
||||
runCatchingExceptions {
|
||||
notificationManagerCompat.notify(
|
||||
|
|
@ -256,6 +281,43 @@ class DefaultActiveCallManager(
|
|||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
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
|
||||
// has joined the call from another session.
|
||||
activeCall
|
||||
|
|
|
|||
|
|
@ -45,8 +45,14 @@ class DefaultCallWidgetProvider(
|
|||
val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
|
||||
val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
|
||||
|
||||
val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
|
||||
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted, direct = room.isDm())
|
||||
val roomInfo = room.info()
|
||||
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(
|
||||
widgetSettings = widgetSettings,
|
||||
clientId = clientId,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import kotlinx.serialization.json.Json
|
|||
import timber.log.Timber
|
||||
import java.util.concurrent.Executors
|
||||
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.
|
||||
|
|
@ -246,7 +246,6 @@ class WebViewAudioManager(
|
|||
private fun registerWebViewDeviceSelectedCallback() {
|
||||
val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge(
|
||||
onAudioDeviceSelected = { selectedDeviceId ->
|
||||
Timber.d("Audio device selected in webview, id: $selectedDeviceId")
|
||||
previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId }
|
||||
audioManager.selectAudioDevice(selectedDeviceId)
|
||||
},
|
||||
|
|
@ -254,7 +253,7 @@ class WebViewAudioManager(
|
|||
coroutineScope.launch(Dispatchers.Main) {
|
||||
// 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
|
||||
delay(500.milliseconds)
|
||||
delay(2.seconds)
|
||||
|
||||
// Calling this ahead of time makes the default audio device to not use the right audio stream
|
||||
setAvailableAudioDevices()
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ class DefaultElementCallEntryPointTest {
|
|||
senderName = "senderName",
|
||||
avatarUrl = "avatarUrl",
|
||||
timestamp = 0,
|
||||
expirationTimestamp = 0,
|
||||
notificationChannelId = "notificationChannelId",
|
||||
textContent = "textContent",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class RingingCallNotificationCreatorTest {
|
|||
roomAvatarUrl = "https://example.com/avatar.jpg",
|
||||
notificationChannelId = "channelId",
|
||||
timestamp = 0L,
|
||||
expirationTimestamp = 20L,
|
||||
textContent = "textContent",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||
}
|
||||
|
||||
@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 widgetDriver = FakeMatrixWidgetDriver()
|
||||
val presenter = createCallScreenPresenter(
|
||||
|
|
@ -238,7 +238,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||
messageInterceptor.givenInterceptedMessage(
|
||||
"""
|
||||
{
|
||||
"action":"io.element.join",
|
||||
"action":"content_loaded",
|
||||
"api":"fromWidget",
|
||||
"widgetId":"1",
|
||||
"requestId":"1"
|
||||
|
|
|
|||
|
|
@ -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.RoomId
|
||||
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_2
|
||||
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_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
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.FakeJoinedRoom
|
||||
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.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.push.FakeNotificationBitmapLoader
|
||||
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.value
|
||||
import io.element.android.tests.testutils.plantTestTimber
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -164,6 +171,102 @@ class DefaultActiveCallManagerTest {
|
|||
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
|
||||
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
|
||||
setupShadowPowerManager()
|
||||
|
|
@ -267,6 +370,83 @@ class DefaultActiveCallManagerTest {
|
|||
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() {
|
||||
shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService<PowerManager>()).apply {
|
||||
setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
|
||||
|
|
@ -277,6 +457,7 @@ class DefaultActiveCallManagerTest {
|
|||
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
|
||||
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
|
||||
systemClock: FakeSystemClock = FakeSystemClock(),
|
||||
) = DefaultActiveCallManager(
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
coroutineScope = backgroundScope,
|
||||
|
|
@ -292,5 +473,6 @@ class DefaultActiveCallManagerTest {
|
|||
defaultCurrentCallService = DefaultCurrentCallService(),
|
||||
appForegroundStateService = FakeAppForegroundStateService(),
|
||||
imageLoaderHolder = FakeImageLoaderHolder(),
|
||||
systemClock = systemClock,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ fun aCallNotificationData(
|
|||
avatarUrl: String? = AN_AVATAR_URL,
|
||||
notificationChannelId: String = "channel_id",
|
||||
timestamp: Long = 0L,
|
||||
expirationTimestamp: Long = 30_000L,
|
||||
textContent: String? = null,
|
||||
): CallNotificationData = CallNotificationData(
|
||||
sessionId = sessionId,
|
||||
|
|
@ -41,5 +42,6 @@ fun aCallNotificationData(
|
|||
avatarUrl = avatarUrl,
|
||||
notificationChannelId = notificationChannelId,
|
||||
timestamp = timestamp,
|
||||
expirationTimestamp = expirationTimestamp,
|
||||
textContent = textContent,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ class FakeElementCallEntryPoint(
|
|||
senderName: String?,
|
||||
avatarUrl: String?,
|
||||
timestamp: Long,
|
||||
expirationTimestamp: Long,
|
||||
notificationChannelId: String,
|
||||
textContent: String?,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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.room.JoinedRoom
|
||||
|
||||
interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
|
||||
fun interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
|
||||
fun builder(parentNode: Node, buildContext: BuildContext): Builder
|
||||
|
||||
interface Builder {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
|
|
@ -37,15 +38,7 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.services.analytics.api)
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
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.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,16 +37,7 @@ class ChangeRolesNode(
|
|||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
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 presenter = presenterFactory.create(inputs.listType.toRoomMemberRole())
|
||||
private val stateFlow = launchMolecule { presenter.present() }
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class ChangeRolesPresenter(
|
|||
private val analyticsService: AnalyticsService,
|
||||
) : Presenter<ChangeRolesState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun interface Factory {
|
||||
fun create(role: RoomMember.Role): ChangeRolesPresenter
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,16 +39,13 @@ class ChangeRoomMemberRolesRootNode(
|
|||
roomComponentFactory: RoomComponentFactory,
|
||||
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
|
||||
navModel = PermanentNavModel(
|
||||
navTargets = setOf(NavTarget.Root),
|
||||
navTargets = setOf(NavTarget),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
), DependencyInjectionGraphOwner, ChangeRoomMemberRolesEntryPoint.NodeProxy {
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Root : NavTarget
|
||||
}
|
||||
@Parcelize object NavTarget : Parcelable
|
||||
|
||||
data class Inputs(
|
||||
val joinedRoom: JoinedRoom,
|
||||
|
|
@ -60,14 +57,10 @@ class ChangeRoomMemberRolesRootNode(
|
|||
override val graph = roomComponentFactory.create(inputs.joinedRoom)
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
createNode<ChangeRolesNode>(
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
|
||||
)
|
||||
}
|
||||
}
|
||||
return createNode<ChangeRolesNode>(
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<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_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_invite_people">"Gwahodd pobl a derbyn ceisiadau i ymuno"</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_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_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_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_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_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_owners_title">"Dewiswch Berchnogion"</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_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_role_administrator">"Gweinyddwr"</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_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_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_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_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_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_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>
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@
|
|||
<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_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_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_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_android">"(Kutsuttu)"</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_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_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>
|
||||
<plurals name="screen_room_member_list_header_title">
|
||||
<item quantity="one">"%1$d henkilö"</item>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,6 @@ import kotlinx.collections.immutable.toPersistentMap
|
|||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.collections.plus
|
||||
|
||||
class ChangeRolesPresenterTest {
|
||||
@Test
|
||||
|
|
@ -556,18 +555,18 @@ class ChangeRolesPresenterTest {
|
|||
users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toPersistentMap()
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createChangeRolesPresenter(
|
||||
role: RoomMember.Role = RoomMember.Role.Admin,
|
||||
room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
|
||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
): ChangeRolesPresenter {
|
||||
return ChangeRolesPresenter(
|
||||
role = role,
|
||||
room = room,
|
||||
dispatchers = dispatchers,
|
||||
analyticsService = analyticsService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun TestScope.createChangeRolesPresenter(
|
||||
role: RoomMember.Role = RoomMember.Role.Admin,
|
||||
room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
|
||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
): ChangeRolesPresenter {
|
||||
return ChangeRolesPresenter(
|
||||
role = role,
|
||||
room = room,
|
||||
dispatchers = dispatchers,
|
||||
analyticsService = analyticsService,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2022-2024 New Vector Ltd.
|
||||
|
|
@ -43,13 +44,7 @@ dependencies {
|
|||
implementation(projects.features.invitepeople.api)
|
||||
api(projects.features.createroom.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.mockk)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.mediapickers.test)
|
||||
|
|
@ -58,7 +53,4 @@ dependencies {
|
|||
testImplementation(projects.libraries.usersearch.test)
|
||||
testImplementation(projects.features.startchat.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
|
|
@ -34,14 +35,6 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
api(projects.features.deactivation.api)
|
||||
|
||||
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(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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>
|
||||
|
|
|
|||
|
|
@ -148,10 +148,10 @@ class AccountDeactivationPresenterTest {
|
|||
assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
) = AccountDeactivationPresenter(
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun createPresenter(
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
) = AccountDeactivationPresenter(
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
|
|
@ -22,8 +23,6 @@ dependencies {
|
|||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,6 @@
|
|||
|
||||
package io.element.android.features.ftue.api
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
|
||||
|
||||
interface FtueEntryPoint : FeatureEntryPoint {
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
fun build(): Node
|
||||
}
|
||||
}
|
||||
interface FtueEntryPoint : SimpleFeatureEntryPoint
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
interface FtueService {
|
||||
/** The current state of the FTUE. */
|
||||
val state: StateFlow<FtueState>
|
||||
|
||||
/** Reset the FTUE state. */
|
||||
suspend fun reset()
|
||||
}
|
||||
|
||||
/** The state of the FTUE. */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
|
|
@ -46,14 +47,7 @@ dependencies {
|
|||
implementation(projects.services.toolbox.api)
|
||||
implementation(projects.appconfig)
|
||||
|
||||
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(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.analytics.noop)
|
||||
|
|
@ -61,5 +55,4 @@ dependencies {
|
|||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.features.lockscreen.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ package io.element.android.features.ftue.impl
|
|||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
|
|
@ -19,13 +18,7 @@ import io.element.android.libraries.architecture.createNode
|
|||
@ContributesBinding(AppScope::class)
|
||||
@Inject
|
||||
class DefaultFtueEntryPoint : FtueEntryPoint {
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
return object : FtueEntryPoint.NodeBuilder {
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<FtueFlowNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
|
||||
return parentNode.createNode<FtueFlowNode>(buildContext)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
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.state.DefaultFtueService
|
||||
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.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
|
|
@ -49,9 +46,8 @@ import kotlinx.parcelize.Parcelize
|
|||
class FtueFlowNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val ftueState: DefaultFtueService,
|
||||
private val defaultFtueService: DefaultFtueService,
|
||||
private val analyticsEntryPoint: AnalyticsEntryPoint,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||
) : BaseFlowNode<FtueFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
|
|
@ -80,19 +76,11 @@ class FtueFlowNode(
|
|||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
|
||||
lifecycle.subscribe(onCreate = {
|
||||
moveToNextStepIfNeeded()
|
||||
})
|
||||
|
||||
analyticsService.didAskUserConsentFlow
|
||||
.distinctUntilChanged()
|
||||
.onEach { moveToNextStepIfNeeded() }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
ftueState.isVerificationStatusKnown
|
||||
.filter { it }
|
||||
.onEach { moveToNextStepIfNeeded() }
|
||||
defaultFtueService.ftueStepStateFlow
|
||||
.filterIsInstance(InternalFtueState.Incomplete::class)
|
||||
.onEach {
|
||||
showStep(it.nextStep)
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +92,7 @@ class FtueFlowNode(
|
|||
is NavTarget.SessionVerification -> {
|
||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||
override fun onDone() {
|
||||
moveToNextStepIfNeeded()
|
||||
defaultFtueService.onUserCompletedSessionVerification()
|
||||
}
|
||||
}
|
||||
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
|
||||
|
|
@ -112,7 +100,7 @@ class FtueFlowNode(
|
|||
NavTarget.NotificationsOptIn -> {
|
||||
val callback = object : NotificationsOptInNode.Callback {
|
||||
override fun onNotificationsOptInFinished() {
|
||||
moveToNextStepIfNeeded()
|
||||
defaultFtueService.updateFtueStep()
|
||||
}
|
||||
}
|
||||
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
|
||||
|
|
@ -123,7 +111,7 @@ class FtueFlowNode(
|
|||
NavTarget.LockScreenSetup -> {
|
||||
val callback = object : LockScreenEntryPoint.Callback {
|
||||
override fun onSetupDone() {
|
||||
moveToNextStepIfNeeded()
|
||||
defaultFtueService.updateFtueStep()
|
||||
}
|
||||
}
|
||||
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
|
||||
|
|
@ -133,8 +121,8 @@ class FtueFlowNode(
|
|||
}
|
||||
}
|
||||
|
||||
private fun moveToNextStepIfNeeded() = lifecycleScope.launch {
|
||||
when (ftueState.getNextStep()) {
|
||||
private fun showStep(ftueStep: FtueStep) {
|
||||
when (ftueStep) {
|
||||
FtueStep.WaitingForInitialState -> {
|
||||
backstack.newRoot(NavTarget.Placeholder)
|
||||
}
|
||||
|
|
@ -150,7 +138,6 @@ class FtueFlowNode(
|
|||
FtueStep.LockscreenSetup -> {
|
||||
backstack.newRoot(NavTarget.LockScreenSetup)
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ package io.element.android.features.ftue.impl.state
|
|||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.ftue.api.state.FtueService
|
||||
import io.element.android.features.ftue.api.state.FtueState
|
||||
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.annotations.SessionCoroutineScope
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@SingleIn(SessionScope::class)
|
||||
@Inject
|
||||
class DefaultFtueService(
|
||||
private val sdkVersionProvider: BuildVersionSdkIntProvider,
|
||||
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val permissionStateProvider: PermissionStateProvider,
|
||||
private val lockScreenService: LockScreenService,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
) : FtueService {
|
||||
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
|
||||
private val userNeedsToConfirmSessionVerificationSuccess = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* 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()
|
||||
val ftueStepStateFlow = MutableStateFlow<InternalFtueState>(InternalFtueState.Unknown)
|
||||
|
||||
override suspend fun reset() {
|
||||
analyticsService.reset()
|
||||
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
override val state = ftueStepStateFlow
|
||||
.mapState {
|
||||
when (it) {
|
||||
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 {
|
||||
sessionVerificationService.sessionVerifiedStatus
|
||||
.onEach { updateState() }
|
||||
.launchIn(sessionCoroutineScope)
|
||||
|
||||
analyticsService.didAskUserConsentFlow
|
||||
.distinctUntilChanged()
|
||||
.onEach { updateState() }
|
||||
.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
|
||||
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
||||
when (currentStep) {
|
||||
private suspend fun getNextStep(completedStep: FtueStep? = null): FtueStep? =
|
||||
when (completedStep) {
|
||||
null -> if (!isSessionVerificationStateReady()) {
|
||||
FtueStep.WaitingForInitialState
|
||||
} else {
|
||||
getNextStep(FtueStep.WaitingForInitialState)
|
||||
}
|
||||
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
|
||||
FtueStep.WaitingForInitialState -> if (isSessionNotVerified() || userNeedsToConfirmSessionVerificationSuccess.value) {
|
||||
FtueStep.SessionVerification
|
||||
} else {
|
||||
getNextStep(FtueStep.SessionVerification)
|
||||
|
|
@ -108,9 +117,6 @@ class DefaultFtueService(
|
|||
}
|
||||
|
||||
private suspend fun isSessionNotVerified(): Boolean {
|
||||
// Wait until the session verification status is known
|
||||
isVerificationStatusKnown.filter { it }.first()
|
||||
|
||||
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
|
||||
}
|
||||
|
||||
|
|
@ -137,14 +143,8 @@ class DefaultFtueService(
|
|||
return lockScreenService.isSetupRequired().first()
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal suspend fun updateState() {
|
||||
val nextStep = getNextStep()
|
||||
state.value = when {
|
||||
// Final state, there aren't any more next steps
|
||||
nextStep == null -> FtueState.Complete
|
||||
else -> FtueState.Incomplete
|
||||
}
|
||||
fun onUserCompletedSessionVerification() {
|
||||
userNeedsToConfirmSessionVerificationSuccess.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.impl.state.DefaultFtueService
|
||||
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.test.FakeLockScreenService
|
||||
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.test.FakeAnalyticsService
|
||||
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.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -69,9 +68,11 @@ class DefaultFtueServiceTest {
|
|||
analyticsService.setDidAskUserConsent()
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
service.updateState()
|
||||
|
||||
assertThat(service.state.value).isEqualTo(FtueState.Complete)
|
||||
service.updateFtueStep()
|
||||
service.state.test {
|
||||
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
|
||||
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -90,9 +91,11 @@ class DefaultFtueServiceTest {
|
|||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
service.updateState()
|
||||
|
||||
assertThat(service.state.value).isEqualTo(FtueState.Complete)
|
||||
service.updateFtueStep()
|
||||
service.state.test {
|
||||
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
|
||||
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -109,35 +112,30 @@ class DefaultFtueServiceTest {
|
|||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
val steps = mutableListOf<FtueStep?>()
|
||||
|
||||
// Session verification
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
|
||||
// Notifications opt in
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
|
||||
// Entering PIN code
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
// Analytics opt in
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
analyticsService.setDidAskUserConsent()
|
||||
|
||||
// Final step (null)
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
|
||||
assertThat(steps).containsExactly(
|
||||
FtueStep.SessionVerification,
|
||||
FtueStep.NotificationsOptIn,
|
||||
FtueStep.LockscreenSetup,
|
||||
FtueStep.AnalyticsOptIn,
|
||||
// Final state
|
||||
null,
|
||||
)
|
||||
service.ftueStepStateFlow.test {
|
||||
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
|
||||
// Session verification
|
||||
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.SessionVerification))
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
// User completes verification
|
||||
service.onUserCompletedSessionVerification()
|
||||
// Notifications opt in
|
||||
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.NotificationsOptIn))
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
// Simulate event from NotificationsOptInNode.Callback.onNotificationsOptInFinished
|
||||
service.updateFtueStep()
|
||||
// Entering PIN code
|
||||
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.LockscreenSetup))
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
// Simulate event from LockScreenEntryPoint.Callback.onSetupDone()
|
||||
service.updateFtueStep()
|
||||
// Analytics opt in
|
||||
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
|
||||
analyticsService.setDidAskUserConsent()
|
||||
// Final step
|
||||
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -158,10 +156,13 @@ class DefaultFtueServiceTest {
|
|||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||
|
||||
analyticsService.setDidAskUserConsent()
|
||||
assertThat(service.getNextStep(null)).isNull()
|
||||
service.ftueStepStateFlow.test {
|
||||
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
|
||||
// Analytics opt in
|
||||
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
|
||||
analyticsService.setDidAskUserConsent()
|
||||
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -180,68 +181,30 @@ class DefaultFtueServiceTest {
|
|||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||
|
||||
analyticsService.setDidAskUserConsent()
|
||||
assertThat(service.getNextStep(null)).isNull()
|
||||
service.ftueStepStateFlow.test {
|
||||
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
|
||||
// Analytics opt in
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.FtueState
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class FakeFtueService(
|
||||
private val resetLambda: () -> Unit = { lambdaError() },
|
||||
) : FtueService {
|
||||
class FakeFtueService : FtueService {
|
||||
override val state: MutableStateFlow<FtueState> = MutableStateFlow(FtueState.Unknown)
|
||||
|
||||
override suspend fun reset() {
|
||||
resetLambda()
|
||||
}
|
||||
|
||||
suspend fun emitState(newState: FtueState) {
|
||||
state.emit(newState)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,5 @@ interface HomeEntryPoint : FeatureEntryPoint {
|
|||
fun onSessionConfirmRecoveryKeyClick()
|
||||
fun onRoomSettingsClick(roomId: RoomId)
|
||||
fun onReportBugClick()
|
||||
fun onLogoutForNativeSlidingSyncMigrationNeeded()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2022-2024 New Vector Ltd.
|
||||
|
|
@ -55,16 +56,10 @@ dependencies {
|
|||
implementation(libs.haze.materials)
|
||||
implementation(projects.features.reportroom.api)
|
||||
implementation(projects.features.changeroommemberroles.api)
|
||||
implementation(projects.libraries.previewutils)
|
||||
api(projects.features.home.api)
|
||||
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
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)
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.features.logout.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
|
|
@ -79,5 +74,4 @@ dependencies {
|
|||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.toolbox.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ class HomeFlowNode(
|
|||
stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false))
|
||||
}
|
||||
|
||||
fun rootNode(buildContext: BuildContext): Node {
|
||||
private fun rootNode(buildContext: BuildContext): Node {
|
||||
return node(buildContext) { modifier ->
|
||||
val state by stateFlow.collectAsState()
|
||||
val activity = requireNotNull(LocalActivity.current)
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ private fun HomeScaffold(
|
|||
.hazeSource(state = hazeState),
|
||||
state = state.homeSpacesState,
|
||||
onSpaceClick = { spaceId ->
|
||||
// TODO
|
||||
onRoomClick(spaceId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.onEach
|
|||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Inject
|
||||
|
|
@ -102,13 +103,44 @@ class RoomListDataSource(
|
|||
}
|
||||
|
||||
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 ->
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
|||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
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.matrix.api.MatrixClient
|
||||
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.roomlist.RoomList
|
||||
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.SessionPreferencesStore
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
|
||||
|
|
@ -101,12 +101,7 @@ class RoomListPresenter(
|
|||
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Avatar indicator
|
||||
val hideInvitesAvatar by remember {
|
||||
client
|
||||
.mediaPreviewService()
|
||||
.mediaPreviewConfigFlow
|
||||
.mapState { config -> config.hideInviteAvatar }
|
||||
}.collectAsState()
|
||||
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
|
||||
|
||||
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
||||
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
|
||||
|
|
|
|||
|
|
@ -13,10 +13,9 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.core.coroutine.mapState
|
||||
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.toPersistentSet
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -28,15 +27,10 @@ class HomeSpacesPresenter(
|
|||
) : Presenter<HomeSpacesState> {
|
||||
@Composable
|
||||
override fun present(): HomeSpacesState {
|
||||
val hideInvitesAvatar by remember {
|
||||
client
|
||||
.mediaPreviewService()
|
||||
.mediaPreviewConfigFlow
|
||||
.mapState { config -> config.hideInviteAvatar }
|
||||
}.collectAsState()
|
||||
val spaceRooms by client.spaceService.spaceRooms.collectAsState(emptyList())
|
||||
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
|
||||
val spaceRooms by client.spaceService.spaceRoomsFlow.collectAsState(emptyList())
|
||||
val seenSpaceInvites by remember {
|
||||
seenInvitesStore.seenSpaceIds().map { it.toPersistentSet() }
|
||||
seenInvitesStore.seenRoomIds().map { it.toPersistentSet() }
|
||||
}.collectAsState(persistentSetOf())
|
||||
|
||||
fun handleEvents(event: HomeSpacesEvents) {
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
|
||||
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 kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
data class HomeSpacesState(
|
||||
val space: CurrentSpace,
|
||||
val spaceRooms: List<SpaceRoom>,
|
||||
val seenSpaceInvites: ImmutableSet<SpaceId>,
|
||||
val seenSpaceInvites: ImmutableSet<RoomId>,
|
||||
val hideInvitesAvatar: Boolean,
|
||||
val eventSink: (HomeSpacesEvents) -> Unit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
package io.element.android.features.home.impl.spaces
|
||||
|
||||
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.previewutils.room.aSpaceRoom
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
|
||||
|
|
@ -18,12 +19,12 @@ open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
|
|||
aHomeSpacesState(
|
||||
spaceRooms = SpaceRoomProvider().values.toList(),
|
||||
seenSpaceInvites = setOf(
|
||||
SpaceId("!spaceId3:example.com"),
|
||||
RoomId("!spaceId3:example.com"),
|
||||
),
|
||||
),
|
||||
aHomeSpacesState(
|
||||
space = CurrentSpace.Space(
|
||||
spaceRoom = aSpaceRooms(spaceId = SpaceId("!mySpace:example.com"))
|
||||
spaceRoom = aSpaceRoom(roomId = RoomId("!mySpace:example.com"))
|
||||
),
|
||||
spaceRooms = aListOfSpaceRooms(),
|
||||
),
|
||||
|
|
@ -33,7 +34,7 @@ open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
|
|||
internal fun aHomeSpacesState(
|
||||
space: CurrentSpace = CurrentSpace.Root,
|
||||
spaceRooms: List<SpaceRoom> = aListOfSpaceRooms(),
|
||||
seenSpaceInvites: Set<SpaceId> = emptySet(),
|
||||
seenSpaceInvites: Set<RoomId> = emptySet(),
|
||||
hideInvitesAvatar: Boolean = false,
|
||||
eventSink: (HomeSpacesEvents) -> Unit = {},
|
||||
) = HomeSpacesState(
|
||||
|
|
@ -46,8 +47,8 @@ internal fun aHomeSpacesState(
|
|||
|
||||
fun aListOfSpaceRooms(): List<SpaceRoom> {
|
||||
return listOf(
|
||||
aSpaceRooms(spaceId = SpaceId("!spaceId0:example.com")),
|
||||
aSpaceRooms(spaceId = SpaceId("!spaceId1:example.com")),
|
||||
aSpaceRooms(spaceId = SpaceId("!spaceId2:example.com")),
|
||||
aSpaceRoom(roomId = RoomId("!spaceId0:example.com")),
|
||||
aSpaceRoom(roomId = RoomId("!spaceId1:example.com")),
|
||||
aSpaceRoom(roomId = RoomId("!spaceId2:example.com")),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.preview.ElementPreview
|
||||
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.ui.components.SpaceHeaderRootView
|
||||
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 kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun HomeSpacesView(
|
||||
state: HomeSpacesState,
|
||||
onSpaceClick: (SpaceId) -> Unit,
|
||||
onSpaceClick: (RoomId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier) {
|
||||
|
|
@ -51,15 +52,18 @@ fun HomeSpacesView(
|
|||
)
|
||||
}
|
||||
}
|
||||
state.spaceRooms.forEach {
|
||||
item(it.spaceId) {
|
||||
val isInvitation = it.state == CurrentUserMembership.INVITED
|
||||
HomeSpaceItemView(
|
||||
spaceRoom = it,
|
||||
showUnreadIndicator = isInvitation && it.spaceId !in state.seenSpaceInvites,
|
||||
state.spaceRooms.forEach { spaceRoom ->
|
||||
item(spaceRoom.roomId) {
|
||||
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
|
||||
SpaceRoomItemView(
|
||||
spaceRoom = spaceRoom,
|
||||
showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
|
||||
hideAvatars = isInvitation && state.hideInvitesAvatar,
|
||||
onClick = {
|
||||
onSpaceClick(it.spaceId)
|
||||
onSpaceClick(spaceRoom.roomId)
|
||||
},
|
||||
onLongClick = {
|
||||
// TODO
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,77 +8,44 @@
|
|||
package io.element.android.features.home.impl.spaces
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
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.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.user.MatrixUser
|
||||
import io.element.android.libraries.previewutils.room.aSpaceRoom
|
||||
|
||||
class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
|
||||
override val values: Sequence<SpaceRoom> = sequenceOf(
|
||||
aSpaceRooms(),
|
||||
aSpaceRooms(
|
||||
aSpaceRoom(),
|
||||
aSpaceRoom(
|
||||
numJoinedMembers = 5,
|
||||
childrenCount = 10,
|
||||
worldReadable = true,
|
||||
spaceId = SpaceId("!spaceId0:example.com"),
|
||||
roomId = RoomId("!spaceId0:example.com"),
|
||||
),
|
||||
aSpaceRooms(
|
||||
aSpaceRoom(
|
||||
numJoinedMembers = 5,
|
||||
childrenCount = 10,
|
||||
worldReadable = true,
|
||||
avatarUrl = "anUrl",
|
||||
spaceId = SpaceId("!spaceId1:example.com"),
|
||||
roomId = RoomId("!spaceId1:example.com"),
|
||||
),
|
||||
aSpaceRooms(
|
||||
aSpaceRoom(
|
||||
name = null,
|
||||
numJoinedMembers = 5,
|
||||
childrenCount = 10,
|
||||
worldReadable = true,
|
||||
avatarUrl = "anUrl",
|
||||
spaceId = SpaceId("!spaceId2:example.com"),
|
||||
roomId = RoomId("!spaceId2:example.com"),
|
||||
state = CurrentUserMembership.INVITED,
|
||||
),
|
||||
aSpaceRooms(
|
||||
aSpaceRoom(
|
||||
name = null,
|
||||
numJoinedMembers = 5,
|
||||
childrenCount = 10,
|
||||
worldReadable = true,
|
||||
avatarUrl = "anUrl",
|
||||
spaceId = SpaceId("!spaceId3:example.com"),
|
||||
roomId = RoomId("!spaceId3:example.com"),
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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_title">"Gwella profiad eich galwadau"</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_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>
|
||||
|
|
@ -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_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_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_title">"Does gennych chi ddim sgyrsiau ar gyfer y dewis hwn"</string>
|
||||
<string name="screen_roomlist_filter_people">"Pobl"</string>
|
||||
|
|
|
|||
11
features/home/impl/src/main/res/values-eo/translations.xml
Normal file
11
features/home/impl/src/main/res/values-eo/translations.xml
Normal 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>
|
||||
|
|
@ -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_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_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_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string>
|
||||
<string name="screen_roomlist_filter_people">"Ihmiset"</string>
|
||||
|
|
|
|||
|
|
@ -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_title">"Forbedre samtaleopplevelsen din"</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_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>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<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_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_empty_list">"Sem convites"</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_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_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_title">"Não tens nenhuma conversa selecionada"</string>
|
||||
<string name="screen_roomlist_filter_people">"Pessoas"</string>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
<string name="screen_roomlist_filter_invites">"邀請"</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_empty_state_title">"您尚無任何低優先程度聊天"</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_people">"夥伴"</string>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -175,24 +175,24 @@ class HomePresenterTest {
|
|||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.isDm
|
||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
|
|
@ -36,3 +37,11 @@ fun RoomInfo.toInviteData(): InviteData {
|
|||
isDm = isDm,
|
||||
)
|
||||
}
|
||||
|
||||
fun SpaceRoom.toInviteData(): InviteData {
|
||||
return InviteData(
|
||||
roomId = roomId,
|
||||
roomName = name ?: roomId.value,
|
||||
isDm = false,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@
|
|||
package io.element.android.features.invite.api
|
||||
|
||||
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.map
|
||||
|
||||
interface SeenInvitesStore {
|
||||
/**
|
||||
|
|
@ -38,9 +35,3 @@ interface SeenInvitesStore {
|
|||
*/
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
fun SeenInvitesStore.seenSpaceIds(): Flow<Set<SpaceId>> {
|
||||
return seenRoomIds().map { roomIds ->
|
||||
roomIds.map { it.toSpaceId() }.toSet()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
interface AcceptDeclineInviteView {
|
||||
fun interface AcceptDeclineInviteView {
|
||||
@Composable
|
||||
fun Render(
|
||||
state: AcceptDeclineInviteState,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,6 @@ import com.bumble.appyx.core.node.Node
|
|||
import io.element.android.features.invite.api.InviteData
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
|
||||
interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
|
||||
fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
|
||||
fun createNode(parentNode: Node, buildContext: BuildContext, inviteData: InviteData): Node
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
|
|
@ -36,17 +37,9 @@ dependencies {
|
|||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.libraries.push.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class DeclineAndBlockPresenter(
|
|||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
) : Presenter<DeclineAndBlockState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun interface Factory {
|
||||
fun create(inviteData: InviteData): DeclineAndBlockPresenter
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<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_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_empty_list">"Sem convites"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>
|
||||
|
|
|
|||
|
|
@ -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.impl.DeclineInvite
|
||||
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.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
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_NAME
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
|
|
@ -148,28 +148,16 @@ class DeclineAndBlockPresenterTest {
|
|||
.isCalledOnce()
|
||||
.with(value(A_ROOM_ID), value(true), value(false), value(""))
|
||||
}
|
||||
|
||||
private fun anInviteData(
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
name: String = A_ROOM_NAME,
|
||||
isDm: Boolean = false,
|
||||
): InviteData {
|
||||
return InviteData(
|
||||
roomId = roomId,
|
||||
roomName = name,
|
||||
isDm = isDm,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createDeclineAndBlockPresenter(
|
||||
inviteData: InviteData = anInviteData(),
|
||||
declineInvite: DeclineInvite = FakeDeclineInvite(),
|
||||
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
||||
): DeclineAndBlockPresenter {
|
||||
return DeclineAndBlockPresenter(
|
||||
inviteData = inviteData,
|
||||
declineInvite = declineInvite,
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun createDeclineAndBlockPresenter(
|
||||
inviteData: InviteData = anInviteData(),
|
||||
declineInvite: DeclineInvite = FakeDeclineInvite(),
|
||||
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
||||
): DeclineAndBlockPresenter {
|
||||
return DeclineAndBlockPresenter(
|
||||
inviteData = inviteData,
|
||||
declineInvite = declineInvite,
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,11 @@
|
|||
|
||||
package io.element.android.features.invitepeople.api
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
interface InvitePeopleState {
|
||||
val canInvite: Boolean
|
||||
val isSearchActive: Boolean
|
||||
val sendInvitesAction: AsyncAction<Unit>
|
||||
val eventSink: (InvitePeopleEvents) -> Unit
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,28 +8,33 @@
|
|||
package io.element.android.features.invitepeople.api
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> {
|
||||
override val values: Sequence<InvitePeopleState>
|
||||
get() = sequenceOf(
|
||||
aPreviewInvitePeopleState(),
|
||||
aPreviewInvitePeopleState(canInvite = true),
|
||||
aPreviewInvitePeopleState(isSearchActive = true)
|
||||
aPreviewInvitePeopleState(isSearchActive = true),
|
||||
aPreviewInvitePeopleState(sendInvitesAction = AsyncAction.Loading),
|
||||
)
|
||||
}
|
||||
|
||||
private data class PreviewInvitePeopleState(
|
||||
override val canInvite: Boolean,
|
||||
override val isSearchActive: Boolean,
|
||||
override val sendInvitesAction: AsyncAction<Unit>,
|
||||
override val eventSink: (InvitePeopleEvents) -> Unit,
|
||||
) : InvitePeopleState
|
||||
|
||||
private fun aPreviewInvitePeopleState(
|
||||
canInvite: Boolean = false,
|
||||
isSearchActive: Boolean = false,
|
||||
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (InvitePeopleEvents) -> Unit = {},
|
||||
) = PreviewInvitePeopleState(
|
||||
canInvite = canInvite,
|
||||
isSearchActive = isSearchActive,
|
||||
sendInvitesAction = sendInvitesAction,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2022-2024 New Vector Ltd.
|
||||
|
|
@ -37,17 +38,8 @@ dependencies {
|
|||
implementation(projects.services.apperror.api)
|
||||
api(projects.features.invitepeople.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.mockk)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.usersearch.test)
|
||||
testImplementation(projects.services.apperror.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ import dev.zacsweers.metro.Inject
|
|||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
||||
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
|
||||
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.map
|
||||
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.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
|
@ -73,6 +75,8 @@ class DefaultInvitePeoplePresenter(
|
|||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchActive by 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()) {
|
||||
if (joinedRoom == null) {
|
||||
val result = matrixClient.getJoinedRoom(roomId)
|
||||
|
|
@ -116,7 +120,7 @@ class DefaultInvitePeoplePresenter(
|
|||
}
|
||||
is InvitePeopleEvents.SendInvites -> {
|
||||
room.dataOrNull()?.let {
|
||||
sessionCoroutineScope.sendInvites(it, selectedUsers.value)
|
||||
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
|
||||
}
|
||||
}
|
||||
is InvitePeopleEvents.CloseSearch -> {
|
||||
|
|
@ -128,12 +132,13 @@ class DefaultInvitePeoplePresenter(
|
|||
|
||||
return DefaultInvitePeopleState(
|
||||
room = room.map { },
|
||||
canInvite = selectedUsers.value.isNotEmpty(),
|
||||
canInvite = selectedUsers.value.isNotEmpty() && !sendInvitesAction.value.isLoading(),
|
||||
selectedUsers = selectedUsers.value,
|
||||
searchQuery = searchQuery,
|
||||
isSearchActive = searchActive,
|
||||
searchResults = searchResults.value,
|
||||
showSearchLoader = showSearchLoader.value,
|
||||
sendInvitesAction = sendInvitesAction.value,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
|
@ -141,16 +146,21 @@ class DefaultInvitePeoplePresenter(
|
|||
private fun CoroutineScope.sendInvites(
|
||||
room: JoinedRoom,
|
||||
selectedUsers: List<MatrixUser>,
|
||||
sendInvitesAction: MutableState<AsyncAction<Unit>>,
|
||||
) = launch {
|
||||
val anyInviteFailed = selectedUsers
|
||||
.map { room.inviteUserById(it.userId) }
|
||||
.any { it.isFailure }
|
||||
sendInvitesAction.runUpdatingState {
|
||||
val anyInviteFailed = selectedUsers
|
||||
.map { room.inviteUserById(it.userId) }
|
||||
.any { it.isFailure }
|
||||
|
||||
if (anyInviteFailed) {
|
||||
appErrorStateService.showError(
|
||||
titleRes = CommonStrings.common_unable_to_invite_title,
|
||||
bodyRes = CommonStrings.common_unable_to_invite_message,
|
||||
)
|
||||
if (anyInviteFailed) {
|
||||
appErrorStateService.showError(
|
||||
titleRes = CommonStrings.common_unable_to_invite_title,
|
||||
bodyRes = CommonStrings.common_unable_to_invite_message,
|
||||
)
|
||||
}
|
||||
|
||||
Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.InvitePeopleState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
|
@ -22,5 +23,6 @@ data class DefaultInvitePeopleState(
|
|||
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
|
||||
val selectedUsers: ImmutableList<MatrixUser>,
|
||||
override val isSearchActive: Boolean,
|
||||
override val sendInvitesAction: AsyncAction<Unit>,
|
||||
override val eventSink: (InvitePeopleEvents) -> Unit
|
||||
) : InvitePeopleState
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
package io.element.android.features.invitepeople.impl
|
||||
|
||||
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.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
|
@ -68,6 +69,11 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
|
|||
showSearchLoader = true,
|
||||
),
|
||||
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(),
|
||||
isSearchActive: Boolean = false,
|
||||
showSearchLoader: Boolean = false,
|
||||
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
): DefaultInvitePeopleState {
|
||||
return DefaultInvitePeopleState(
|
||||
room = room,
|
||||
|
|
@ -102,6 +109,7 @@ private fun aDefaultInvitePeopleState(
|
|||
selectedUsers = selectedUsers,
|
||||
isSearchActive = isSearchActive,
|
||||
showSearchLoader = showSearchLoader,
|
||||
sendInvitesAction = sendInvitesAction,
|
||||
eventSink = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -409,10 +409,23 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
// Send invites
|
||||
initialState.eventSink(InvitePeopleEvents.SendInvites)
|
||||
|
||||
// Can't invite in the loading state
|
||||
awaitItem().run {
|
||||
assertThat(sendInvitesAction.isLoading()).isTrue()
|
||||
assertThat(canInvite).isFalse()
|
||||
}
|
||||
|
||||
delay(1_000)
|
||||
inviteUserResult.assertions().isCalledOnce().with(
|
||||
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)
|
||||
// Send invites
|
||||
initialState.eventSink(InvitePeopleEvents.SendInvites)
|
||||
|
||||
// Can't invite in the loading state
|
||||
awaitItem().run {
|
||||
assertThat(sendInvitesAction.isLoading()).isTrue()
|
||||
assertThat(canInvite).isFalse()
|
||||
}
|
||||
|
||||
delay(1_000)
|
||||
inviteUserResult.assertions().isCalledOnce().with(
|
||||
value(selectedUser.userId)
|
||||
|
|
@ -455,6 +475,12 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||
value(CommonStrings.common_unable_to_invite_title),
|
||||
value(CommonStrings.common_unable_to_invite_message)
|
||||
)
|
||||
|
||||
// Can invite again once the action is finished
|
||||
awaitItem().run {
|
||||
assertThat(sendInvitesAction.isReady()).isTrue()
|
||||
assertThat(canInvite).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
|
|
@ -38,16 +39,9 @@ dependencies {
|
|||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.appconfig)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
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
Loading…
Add table
Add a link
Reference in a new issue