Merge branch 'release/25.09.2' into main
This commit is contained in:
commit
da5031b41c
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.
|
# Android Studio 3 in .gitignore file.
|
||||||
.idea/caches
|
.idea/caches
|
||||||
.idea/copilot
|
.idea/copilot
|
||||||
|
.idea/copilot.*
|
||||||
.idea/inspectionProfiles
|
.idea/inspectionProfiles
|
||||||
# Shelved changes in the IDE
|
# Shelved changes in the IDE
|
||||||
.idea/shelf
|
.idea/shelf
|
||||||
|
|
|
||||||
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
|
Changes in Element X v25.09.0
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import extension.koverDependencies
|
||||||
import extension.locales
|
import extension.locales
|
||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
import extension.setupKover
|
import extension.setupKover
|
||||||
|
import extension.testCommonDependencies
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
|
|
@ -290,15 +291,9 @@ dependencies {
|
||||||
|
|
||||||
implementation(libs.matrix.emojibase.bindings)
|
implementation(libs.matrix.emojibase.bindings)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testCommonDependencies(libs)
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.services.toolbox.test)
|
testImplementation(projects.services.toolbox.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
|
|
||||||
koverDependencies()
|
koverDependencies()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
<locale android:name="el"/>
|
<locale android:name="el"/>
|
||||||
<locale android:name="en"/>
|
<locale android:name="en"/>
|
||||||
<locale android:name="en_US"/>
|
<locale android:name="en_US"/>
|
||||||
|
<locale android:name="eo"/>
|
||||||
<locale android:name="es"/>
|
<locale android:name="es"/>
|
||||||
<locale android:name="et"/>
|
<locale android:name="et"/>
|
||||||
<locale android:name="eu"/>
|
<locale android:name="eu"/>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import extension.allFeaturesApi
|
import extension.allFeaturesApi
|
||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
|
|
@ -42,18 +43,12 @@ dependencies {
|
||||||
|
|
||||||
implementation(projects.features.ftue.api)
|
implementation(projects.features.ftue.api)
|
||||||
implementation(projects.features.share.api)
|
implementation(projects.features.share.api)
|
||||||
implementation(projects.features.viewfolder.api)
|
|
||||||
|
|
||||||
implementation(projects.services.apperror.impl)
|
implementation(projects.services.apperror.impl)
|
||||||
implementation(projects.services.appnavstate.api)
|
implementation(projects.services.appnavstate.api)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testCommonDependencies(libs)
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(projects.features.login.test)
|
testImplementation(projects.features.login.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.oidc.test)
|
testImplementation(projects.libraries.oidc.test)
|
||||||
|
|
@ -61,11 +56,8 @@ dependencies {
|
||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.libraries.pushproviders.test)
|
testImplementation(projects.libraries.pushproviders.test)
|
||||||
testImplementation(projects.features.networkmonitor.test)
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
testImplementation(projects.features.rageshake.test)
|
testImplementation(projects.features.rageshake.test)
|
||||||
testImplementation(projects.services.appnavstate.test)
|
testImplementation(projects.services.appnavstate.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.services.toolbox.test)
|
testImplementation(projects.services.toolbox.test)
|
||||||
testImplementation(libs.test.appyx.junit)
|
|
||||||
testImplementation(libs.test.arch.core)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ import io.element.android.features.ftue.api.FtueEntryPoint
|
||||||
import io.element.android.features.ftue.api.state.FtueService
|
import io.element.android.features.ftue.api.state.FtueService
|
||||||
import io.element.android.features.ftue.api.state.FtueState
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
import io.element.android.features.home.api.HomeEntryPoint
|
import io.element.android.features.home.api.HomeEntryPoint
|
||||||
import io.element.android.features.logout.api.LogoutEntryPoint
|
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||||
|
|
@ -60,6 +59,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
|
||||||
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
|
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
|
||||||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||||
import io.element.android.features.share.api.ShareEntryPoint
|
import io.element.android.features.share.api.ShareEntryPoint
|
||||||
|
import io.element.android.features.space.api.SpaceEntryPoint
|
||||||
import io.element.android.features.startchat.api.StartChatEntryPoint
|
import io.element.android.features.startchat.api.StartChatEntryPoint
|
||||||
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
||||||
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
|
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
|
||||||
|
|
@ -118,7 +118,6 @@ class LoggedInFlowNode(
|
||||||
private val shareEntryPoint: ShareEntryPoint,
|
private val shareEntryPoint: ShareEntryPoint,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val sendingQueue: SendQueues,
|
private val sendingQueue: SendQueues,
|
||||||
private val logoutEntryPoint: LogoutEntryPoint,
|
|
||||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||||
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
|
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
|
||||||
private val sessionEnterpriseService: SessionEnterpriseService,
|
private val sessionEnterpriseService: SessionEnterpriseService,
|
||||||
|
|
@ -276,9 +275,6 @@ class LoggedInFlowNode(
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class IncomingShare(val intent: Intent) : NavTarget
|
data class IncomingShare(val intent: Intent) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
|
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
|
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
@ -323,10 +319,6 @@ class LoggedInFlowNode(
|
||||||
override fun onReportBugClick() {
|
override fun onReportBugClick() {
|
||||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLogoutForNativeSlidingSyncMigrationNeeded() {
|
|
||||||
backstack.push(NavTarget.LogoutForNativeSlidingSyncMigrationNeeded)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
homeEntryPoint
|
homeEntryPoint
|
||||||
.nodeBuilder(this, buildContext)
|
.nodeBuilder(this, buildContext)
|
||||||
|
|
@ -334,7 +326,7 @@ class LoggedInFlowNode(
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
is NavTarget.Room -> {
|
is NavTarget.Room -> {
|
||||||
val callback = object : JoinedRoomLoadedFlowNode.Callback {
|
val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback {
|
||||||
override fun onOpenRoom(roomId: RoomId, serverNames: List<String>) {
|
override fun onOpenRoom(roomId: RoomId, serverNames: List<String>) {
|
||||||
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames))
|
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames))
|
||||||
}
|
}
|
||||||
|
|
@ -373,6 +365,11 @@ class LoggedInFlowNode(
|
||||||
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
|
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val spaceCallback = object : SpaceEntryPoint.Callback {
|
||||||
|
override fun onOpenRoom(roomId: RoomId) {
|
||||||
|
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
|
||||||
|
}
|
||||||
|
}
|
||||||
val inputs = RoomFlowNode.Inputs(
|
val inputs = RoomFlowNode.Inputs(
|
||||||
roomIdOrAlias = navTarget.roomIdOrAlias,
|
roomIdOrAlias = navTarget.roomIdOrAlias,
|
||||||
roomDescription = Optional.ofNullable(navTarget.roomDescription),
|
roomDescription = Optional.ofNullable(navTarget.roomDescription),
|
||||||
|
|
@ -380,7 +377,7 @@ class LoggedInFlowNode(
|
||||||
trigger = Optional.ofNullable(navTarget.trigger),
|
trigger = Optional.ofNullable(navTarget.trigger),
|
||||||
initialElement = navTarget.initialElement
|
initialElement = navTarget.initialElement
|
||||||
)
|
)
|
||||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback))
|
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, joinedRoomCallback, spaceCallback))
|
||||||
}
|
}
|
||||||
is NavTarget.UserProfile -> {
|
is NavTarget.UserProfile -> {
|
||||||
val callback = object : UserProfileEntryPoint.Callback {
|
val callback = object : UserProfileEntryPoint.Callback {
|
||||||
|
|
@ -448,8 +445,7 @@ class LoggedInFlowNode(
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
NavTarget.Ftue -> {
|
NavTarget.Ftue -> {
|
||||||
ftueEntryPoint.nodeBuilder(this, buildContext)
|
ftueEntryPoint.createNode(this, buildContext)
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
NavTarget.RoomDirectorySearch -> {
|
NavTarget.RoomDirectorySearch -> {
|
||||||
roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
|
roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
|
||||||
|
|
@ -480,17 +476,6 @@ class LoggedInFlowNode(
|
||||||
.params(ShareEntryPoint.Params(intent = navTarget.intent))
|
.params(ShareEntryPoint.Params(intent = navTarget.intent))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
is NavTarget.LogoutForNativeSlidingSyncMigrationNeeded -> {
|
|
||||||
val callback = object : LogoutEntryPoint.Callback {
|
|
||||||
override fun onChangeRecoveryKeyClick() {
|
|
||||||
backstack.push(NavTarget.SecureBackup())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logoutEntryPoint.nodeBuilder(this, buildContext)
|
|
||||||
.callback(callback)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
is NavTarget.IncomingVerificationRequest -> {
|
is NavTarget.IncomingVerificationRequest -> {
|
||||||
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
|
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
|
||||||
.params(IncomingVerificationEntryPoint.Params(navTarget.data))
|
.params(IncomingVerificationEntryPoint.Params(navTarget.data))
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ import io.element.android.features.login.api.accesscontrol.AccountProviderAccess
|
||||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||||
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
||||||
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
|
|
||||||
import io.element.android.libraries.architecture.BackstackView
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
|
@ -47,13 +46,13 @@ import io.element.android.libraries.architecture.waitForChildAttached
|
||||||
import io.element.android.libraries.core.uri.ensureProtocol
|
import io.element.android.libraries.core.uri.ensureProtocol
|
||||||
import io.element.android.libraries.deeplink.api.DeeplinkData
|
import io.element.android.libraries.deeplink.api.DeeplinkData
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
import io.element.android.libraries.oidc.api.OidcAction
|
import io.element.android.libraries.oidc.api.OidcAction
|
||||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
@ -65,13 +64,12 @@ import timber.log.Timber
|
||||||
class RootFlowNode(
|
class RootFlowNode(
|
||||||
@Assisted val buildContext: BuildContext,
|
@Assisted val buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val sessionStore: SessionStore,
|
||||||
private val accountProviderAccessControl: AccountProviderAccessControl,
|
private val accountProviderAccessControl: AccountProviderAccessControl,
|
||||||
private val navStateFlowFactory: RootNavStateFlowFactory,
|
private val navStateFlowFactory: RootNavStateFlowFactory,
|
||||||
private val matrixSessionCache: MatrixSessionCache,
|
private val matrixSessionCache: MatrixSessionCache,
|
||||||
private val presenter: RootPresenter,
|
private val presenter: RootPresenter,
|
||||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||||
private val viewFolderEntryPoint: ViewFolderEntryPoint,
|
|
||||||
private val signedOutEntryPoint: SignedOutEntryPoint,
|
private val signedOutEntryPoint: SignedOutEntryPoint,
|
||||||
private val intentResolver: IntentResolver,
|
private val intentResolver: IntentResolver,
|
||||||
private val oidcActionFlow: OidcActionFlow,
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
|
|
@ -154,7 +152,7 @@ class RootFlowNode(
|
||||||
onSuccess: (SessionId) -> Unit,
|
onSuccess: (SessionId) -> Unit,
|
||||||
onFailure: () -> Unit
|
onFailure: () -> Unit
|
||||||
) {
|
) {
|
||||||
val latestSessionId = authenticationService.getLatestSessionId()
|
val latestSessionId = sessionStore.getLatestSessionId()
|
||||||
if (latestSessionId == null) {
|
if (latestSessionId == null) {
|
||||||
onFailure()
|
onFailure()
|
||||||
return
|
return
|
||||||
|
|
@ -200,11 +198,6 @@ class RootFlowNode(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object BugReport : NavTarget
|
data object BugReport : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class ViewLogs(
|
|
||||||
val rootPath: String,
|
|
||||||
) : NavTarget
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -244,31 +237,12 @@ class RootFlowNode(
|
||||||
NavTarget.SplashScreen -> splashNode(buildContext)
|
NavTarget.SplashScreen -> splashNode(buildContext)
|
||||||
NavTarget.BugReport -> {
|
NavTarget.BugReport -> {
|
||||||
val callback = object : BugReportEntryPoint.Callback {
|
val callback = object : BugReportEntryPoint.Callback {
|
||||||
override fun onBugReportSent() {
|
|
||||||
backstack.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewLogs(basePath: String) {
|
|
||||||
backstack.push(NavTarget.ViewLogs(rootPath = basePath))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bugReportEntryPoint
|
|
||||||
.nodeBuilder(this, buildContext)
|
|
||||||
.callback(callback)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
is NavTarget.ViewLogs -> {
|
|
||||||
val callback = object : ViewFolderEntryPoint.Callback {
|
|
||||||
override fun onDone() {
|
override fun onDone() {
|
||||||
backstack.pop()
|
backstack.pop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val params = ViewFolderEntryPoint.Params(
|
bugReportEntryPoint
|
||||||
rootPath = navTarget.rootPath,
|
|
||||||
)
|
|
||||||
viewFolderEntryPoint
|
|
||||||
.nodeBuilder(this, buildContext)
|
.nodeBuilder(this, buildContext)
|
||||||
.params(params)
|
|
||||||
.callback(callback)
|
.callback(callback)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
@ -294,7 +268,7 @@ class RootFlowNode(
|
||||||
|
|
||||||
private suspend fun onLoginLink(params: LoginParams) {
|
private suspend fun onLoginLink(params: LoginParams) {
|
||||||
// Is there a session already?
|
// Is there a session already?
|
||||||
val latestSessionId = authenticationService.getLatestSessionId()
|
val latestSessionId = sessionStore.getLatestSessionId()
|
||||||
if (latestSessionId == null) {
|
if (latestSessionId == null) {
|
||||||
// No session, open login
|
// No session, open login
|
||||||
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
|
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
|
||||||
|
|
@ -311,7 +285,7 @@ class RootFlowNode(
|
||||||
|
|
||||||
private suspend fun onIncomingShare(intent: Intent) {
|
private suspend fun onIncomingShare(intent: Intent) {
|
||||||
// Is there a session already?
|
// Is there a session already?
|
||||||
val latestSessionId = authenticationService.getLatestSessionId()
|
val latestSessionId = sessionStore.getLatestSessionId()
|
||||||
if (latestSessionId == null) {
|
if (latestSessionId == null) {
|
||||||
// No session, open login
|
// No session, open login
|
||||||
switchToNotLoggedInFlow(null)
|
switchToNotLoggedInFlow(null)
|
||||||
|
|
@ -368,3 +342,5 @@ class RootFlowNode(
|
||||||
.attachSession()
|
.attachSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,6 @@ package io.element.android.appnav.di
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
|
||||||
interface RoomComponentFactory {
|
fun interface RoomComponentFactory {
|
||||||
fun create(room: JoinedRoom): Any
|
fun create(room: JoinedRoom): Any
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,9 @@ import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
|
||||||
import io.element.android.appnav.room.joined.LoadingRoomNodeView
|
import io.element.android.appnav.room.joined.LoadingRoomNodeView
|
||||||
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
|
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
|
||||||
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
|
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
|
||||||
|
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params
|
||||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||||
|
import io.element.android.features.space.api.SpaceEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackView
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
|
@ -52,7 +54,6 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.shareIn
|
import kotlinx.coroutines.flow.shareIn
|
||||||
|
|
@ -72,6 +73,7 @@ class RoomFlowNode(
|
||||||
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
|
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
|
||||||
private val syncService: SyncService,
|
private val syncService: SyncService,
|
||||||
private val membershipObserver: RoomMembershipObserver,
|
private val membershipObserver: RoomMembershipObserver,
|
||||||
|
private val spaceEntryPoint: SpaceEntryPoint,
|
||||||
) : BaseFlowNode<RoomFlowNode.NavTarget>(
|
) : BaseFlowNode<RoomFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Loading,
|
initialElement = NavTarget.Loading,
|
||||||
|
|
@ -106,6 +108,9 @@ class RoomFlowNode(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class JoinedRoom(val roomId: RoomId) : NavTarget
|
data class JoinedRoom(val roomId: RoomId) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class JoinedSpace(val spaceId: RoomId) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBuilt() {
|
override fun onBuilt() {
|
||||||
|
|
@ -143,40 +148,28 @@ class RoomFlowNode(
|
||||||
.withPreviousValue()
|
.withPreviousValue()
|
||||||
combine(currentMembershipFlow, isSpaceFlow) { (previousMembership, membership), isSpace ->
|
combine(currentMembershipFlow, isSpaceFlow) { (previousMembership, membership), isSpace ->
|
||||||
Timber.d("Room membership: $membership")
|
Timber.d("Room membership: $membership")
|
||||||
when (membership) {
|
if (membership == CurrentUserMembership.JOINED) {
|
||||||
CurrentUserMembership.JOINED -> {
|
if (isSpace) {
|
||||||
if (isSpace) {
|
backstack.newRoot(NavTarget.JoinedSpace(spaceId = roomId))
|
||||||
// It should not happen, but probably due to an issue in the sliding sync,
|
} else {
|
||||||
// we can have a space here in case the space has just been joined.
|
backstack.newRoot(NavTarget.JoinedRoom(roomId))
|
||||||
// So navigate to the JoinRoom target for now, which will
|
|
||||||
// handle the space not supported screen
|
|
||||||
backstack.newRoot(
|
|
||||||
NavTarget.JoinRoom(
|
|
||||||
roomId = roomId,
|
|
||||||
serverNames = serverNames,
|
|
||||||
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
backstack.newRoot(NavTarget.JoinedRoom(roomId))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else -> {
|
} else {
|
||||||
if (membership == CurrentUserMembership.LEFT && previousMembership == CurrentUserMembership.JOINED) {
|
val leavingFromCurrentDevice =
|
||||||
// The user left the room in this device, remove the room from the backstack
|
membership == CurrentUserMembership.LEFT &&
|
||||||
if (!membershipUpdateFlow.first().isUserInRoom) {
|
previousMembership == CurrentUserMembership.JOINED &&
|
||||||
navigateUp()
|
membershipUpdateFlow.replayCache.lastOrNull()?.isUserInRoom == false
|
||||||
}
|
|
||||||
} else {
|
if (leavingFromCurrentDevice) {
|
||||||
// Was invited or the room is not known, display the join room screen
|
navigateUp()
|
||||||
backstack.newRoot(
|
} else {
|
||||||
NavTarget.JoinRoom(
|
backstack.newRoot(
|
||||||
roomId = roomId,
|
NavTarget.JoinRoom(
|
||||||
serverNames = serverNames,
|
roomId = roomId,
|
||||||
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite,
|
serverNames = serverNames,
|
||||||
)
|
trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite,
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.launchIn(lifecycleScope)
|
}.launchIn(lifecycleScope)
|
||||||
|
|
@ -194,7 +187,7 @@ class RoomFlowNode(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val params = RoomAliasResolverEntryPoint.Params(navTarget.roomAlias)
|
val params = Params(navTarget.roomAlias)
|
||||||
roomAliasResolverEntryPoint.nodeBuilder(this, buildContext)
|
roomAliasResolverEntryPoint.nodeBuilder(this, buildContext)
|
||||||
.callback(callback)
|
.callback(callback)
|
||||||
.params(params)
|
.params(params)
|
||||||
|
|
@ -218,6 +211,13 @@ class RoomFlowNode(
|
||||||
)
|
)
|
||||||
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
|
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
|
||||||
}
|
}
|
||||||
|
is NavTarget.JoinedSpace -> {
|
||||||
|
val spaceCallback = plugins<SpaceEntryPoint.Callback>().single()
|
||||||
|
spaceEntryPoint.nodeBuilder(this, buildContext)
|
||||||
|
.inputs(SpaceEntryPoint.Inputs(roomId = navTarget.spaceId))
|
||||||
|
.callback(spaceCallback)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ import com.bumble.appyx.core.state.SavedStateMap
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.appnav.di.MatrixSessionCache
|
import io.element.android.appnav.di.MatrixSessionCache
|
||||||
import io.element.android.features.preferences.api.CacheService
|
import io.element.android.features.preferences.api.CacheService
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
|
||||||
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
|
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
@ -28,7 +28,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFact
|
||||||
*/
|
*/
|
||||||
@Inject
|
@Inject
|
||||||
class RootNavStateFlowFactory(
|
class RootNavStateFlowFactory(
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val sessionStore: SessionStore,
|
||||||
private val cacheService: CacheService,
|
private val cacheService: CacheService,
|
||||||
private val matrixSessionCache: MatrixSessionCache,
|
private val matrixSessionCache: MatrixSessionCache,
|
||||||
private val imageLoaderHolder: ImageLoaderHolder,
|
private val imageLoaderHolder: ImageLoaderHolder,
|
||||||
|
|
@ -39,7 +39,7 @@ class RootNavStateFlowFactory(
|
||||||
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
|
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
|
||||||
return combine(
|
return combine(
|
||||||
cacheIndexFlow(savedStateMap),
|
cacheIndexFlow(savedStateMap),
|
||||||
authenticationService.loggedInStateFlow(),
|
sessionStore.loggedInStateFlow(),
|
||||||
) { cacheIndex, loggedInState ->
|
) { cacheIndex, loggedInState ->
|
||||||
RootNavState(
|
RootNavState(
|
||||||
cacheIndex = cacheIndex,
|
cacheIndex = cacheIndex,
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="banner_migrate_to_native_sliding_sync_action">"Sair & Atualizar"</string>
|
<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_app_force_logout_title">"%1$s já não suporta o protocolo antigo. Termina a sessão e volta a iniciar sessão para continuares a utilizar a aplicação."</string>
|
||||||
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Seu homeserver não suporta mais o protocolo antigo. Termine sessão e volte a iniciar sessão para continuar a utilizar a aplicação."</string>
|
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"O teu servidor já não permite o protocolo antigo. Termine sessão e volte a iniciá-la para continuar a utilizar a aplicação."</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -501,22 +501,16 @@ class LoggedInPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - CheckSlidingSyncProxyAvailability forces the sliding sync migration under the right circumstances`() = runTest {
|
fun `present - CheckSlidingSyncProxyAvailability forces the sliding sync migration under the right circumstances`() = runTest {
|
||||||
// The migration will be forced if:
|
// The migration will be forced if the user is not using the native sliding sync
|
||||||
// - The user is not using the native sliding sync
|
|
||||||
// - The sliding sync proxy is no longer supported
|
|
||||||
// - The native sliding sync is supported
|
|
||||||
val matrixClient = FakeMatrixClient(
|
val matrixClient = FakeMatrixClient(
|
||||||
currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
|
currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
|
||||||
availableSlidingSyncVersionsLambda = { Result.success(listOf(SlidingSyncVersion.Native)) },
|
|
||||||
)
|
)
|
||||||
createLoggedInPresenter(
|
createLoggedInPresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
).test {
|
).test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
|
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
|
||||||
|
|
||||||
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
|
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
|
||||||
|
|
||||||
assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue()
|
assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ allprojects {
|
||||||
// Fix compilation warning for annotations
|
// Fix compilation warning for annotations
|
||||||
// See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
|
// See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
|
||||||
freeCompilerArgs.add("-Xannotation-default-target=first-only")
|
freeCompilerArgs.add("-Xannotation-default-target=first-only")
|
||||||
|
// Opt-in to context receivers
|
||||||
|
freeCompilerArgs.add("-Xcontext-parameters")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
|
||||||
|
|
||||||
interface AnalyticsEntryPoint : SimpleFeatureEntryPoint
|
fun interface AnalyticsEntryPoint : SimpleFeatureEntryPoint
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
* Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
|
@ -30,13 +31,7 @@ dependencies {
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.browser)
|
implementation(libs.androidx.browser)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testCommonDependencies(libs)
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(libs.test.mockk)
|
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
* Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
|
@ -22,8 +23,5 @@ dependencies {
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testCommonDependencies(libs)
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ interface ElementCallEntryPoint {
|
||||||
* @param senderName The name of the sender of the event that started the call.
|
* @param senderName The name of the sender of the event that started the call.
|
||||||
* @param avatarUrl The avatar url of the room or DM.
|
* @param avatarUrl The avatar url of the room or DM.
|
||||||
* @param timestamp The timestamp of the event that started the call.
|
* @param timestamp The timestamp of the event that started the call.
|
||||||
|
* @param expirationTimestamp The timestamp at which the call should stop ringing.
|
||||||
* @param notificationChannelId The id of the notification channel to use for the call notification.
|
* @param notificationChannelId The id of the notification channel to use for the call notification.
|
||||||
* @param textContent The text content of the notification. If null the default content from the system will be used.
|
* @param textContent The text content of the notification. If null the default content from the system will be used.
|
||||||
*/
|
*/
|
||||||
|
|
@ -40,6 +41,7 @@ interface ElementCallEntryPoint {
|
||||||
senderName: String?,
|
senderName: String?,
|
||||||
avatarUrl: String?,
|
avatarUrl: String?,
|
||||||
timestamp: Long,
|
timestamp: Long,
|
||||||
|
expirationTimestamp: Long,
|
||||||
notificationChannelId: String,
|
notificationChannelId: String,
|
||||||
textContent: String?,
|
textContent: String?,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import extension.buildConfigFieldStr
|
import extension.buildConfigFieldStr
|
||||||
import extension.readLocalProperty
|
import extension.readLocalProperty
|
||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2024 New Vector Ltd.
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
|
@ -87,12 +88,7 @@ dependencies {
|
||||||
implementation(libs.element.call.embedded)
|
implementation(libs.element.call.embedded)
|
||||||
api(projects.features.call.api)
|
api(projects.features.call.api)
|
||||||
|
|
||||||
testImplementation(libs.coroutines.test)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(libs.test.mockk)
|
|
||||||
testImplementation(projects.features.call.test)
|
testImplementation(projects.features.call.test)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
testImplementation(projects.libraries.preferences.test)
|
testImplementation(projects.libraries.preferences.test)
|
||||||
|
|
@ -100,7 +96,5 @@ dependencies {
|
||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.services.appnavstate.test)
|
testImplementation(projects.services.appnavstate.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.services.toolbox.test)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ class DefaultElementCallEntryPoint(
|
||||||
senderName: String?,
|
senderName: String?,
|
||||||
avatarUrl: String?,
|
avatarUrl: String?,
|
||||||
timestamp: Long,
|
timestamp: Long,
|
||||||
|
expirationTimestamp: Long,
|
||||||
notificationChannelId: String,
|
notificationChannelId: String,
|
||||||
textContent: String?,
|
textContent: String?,
|
||||||
) {
|
) {
|
||||||
|
|
@ -55,6 +56,7 @@ class DefaultElementCallEntryPoint(
|
||||||
senderName = senderName,
|
senderName = senderName,
|
||||||
avatarUrl = avatarUrl,
|
avatarUrl = avatarUrl,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
|
expirationTimestamp = expirationTimestamp,
|
||||||
notificationChannelId = notificationChannelId,
|
notificationChannelId = notificationChannelId,
|
||||||
textContent = textContent,
|
textContent = textContent,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,5 +41,8 @@ data class WidgetMessage(
|
||||||
|
|
||||||
@SerialName("send_event")
|
@SerialName("send_event")
|
||||||
SendEvent,
|
SendEvent,
|
||||||
|
|
||||||
|
@SerialName("content_loaded")
|
||||||
|
ContentLoaded,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,6 @@ data class CallNotificationData(
|
||||||
val notificationChannelId: String,
|
val notificationChannelId: String,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val textContent: String?,
|
val textContent: String?,
|
||||||
|
// Expiration timestamp in millis since epoch
|
||||||
|
val expirationTimestamp: Long,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ class RingingCallNotificationCreator(
|
||||||
roomAvatarUrl: String?,
|
roomAvatarUrl: String?,
|
||||||
notificationChannelId: String,
|
notificationChannelId: String,
|
||||||
timestamp: Long,
|
timestamp: Long,
|
||||||
|
expirationTimestamp: Long,
|
||||||
textContent: String?,
|
textContent: String?,
|
||||||
): Notification? {
|
): Notification? {
|
||||||
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
|
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
|
||||||
|
|
@ -88,6 +89,7 @@ class RingingCallNotificationCreator(
|
||||||
notificationChannelId = notificationChannelId,
|
notificationChannelId = notificationChannelId,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
textContent = textContent,
|
textContent = textContent,
|
||||||
|
expirationTimestamp = expirationTimestamp,
|
||||||
)
|
)
|
||||||
|
|
||||||
val declineIntent = PendingIntentCompat.getBroadcast(
|
val declineIntent = PendingIntentCompat.getBroadcast(
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ class CallScreenPresenter(
|
||||||
val urlState = remember { mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized) }
|
val urlState = remember { mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized) }
|
||||||
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) }
|
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) }
|
||||||
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
|
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
|
||||||
var isJoinedCall by rememberSaveable { mutableStateOf(false) }
|
var isWidgetLoaded by rememberSaveable { mutableStateOf(false) }
|
||||||
var ignoreWebViewError by rememberSaveable { mutableStateOf(false) }
|
var ignoreWebViewError by rememberSaveable { mutableStateOf(false) }
|
||||||
var webViewError by remember { mutableStateOf<String?>(null) }
|
var webViewError by remember { mutableStateOf<String?>(null) }
|
||||||
val languageTag = languageTagProvider.provideLanguageTag()
|
val languageTag = languageTagProvider.provideLanguageTag()
|
||||||
|
|
@ -139,8 +139,8 @@ class CallScreenPresenter(
|
||||||
if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) {
|
if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) {
|
||||||
if (parsedMessage.action == WidgetMessage.Action.Close) {
|
if (parsedMessage.action == WidgetMessage.Action.Close) {
|
||||||
close(callWidgetDriver.value, navigator)
|
close(callWidgetDriver.value, navigator)
|
||||||
} else if (parsedMessage.action == WidgetMessage.Action.Join) {
|
} else if (parsedMessage.action == WidgetMessage.Action.ContentLoaded) {
|
||||||
isJoinedCall = true
|
isWidgetLoaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,8 +151,8 @@ class CallScreenPresenter(
|
||||||
// Wait for the call to be joined, if it takes too long, we display an error
|
// Wait for the call to be joined, if it takes too long, we display an error
|
||||||
delay(10.seconds)
|
delay(10.seconds)
|
||||||
|
|
||||||
if (!isJoinedCall) {
|
if (!isWidgetLoaded) {
|
||||||
Timber.w("The call took too long to be joined. Displaying an error before exiting.")
|
Timber.w("The call took too long to load. Displaying an error before exiting.")
|
||||||
|
|
||||||
// This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
|
// This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
|
||||||
webViewError = ""
|
webViewError = ""
|
||||||
|
|
@ -165,10 +165,10 @@ class CallScreenPresenter(
|
||||||
is CallScreenEvents.Hangup -> {
|
is CallScreenEvents.Hangup -> {
|
||||||
val widgetId = callWidgetDriver.value?.id
|
val widgetId = callWidgetDriver.value?.id
|
||||||
val interceptor = messageInterceptor.value
|
val interceptor = messageInterceptor.value
|
||||||
if (widgetId != null && interceptor != null && isJoinedCall) {
|
if (widgetId != null && interceptor != null && isWidgetLoaded) {
|
||||||
// If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
|
// If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
|
||||||
sendHangupMessage(widgetId, interceptor)
|
sendHangupMessage(widgetId, interceptor)
|
||||||
isJoinedCall = false
|
isWidgetLoaded = false
|
||||||
|
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
// Wait for a couple of seconds to receive the hangup message
|
// Wait for a couple of seconds to receive the hangup message
|
||||||
|
|
@ -198,7 +198,7 @@ class CallScreenPresenter(
|
||||||
urlState = urlState.value,
|
urlState = urlState.value,
|
||||||
webViewError = webViewError,
|
webViewError = webViewError,
|
||||||
userAgent = userAgent,
|
userAgent = userAgent,
|
||||||
isCallActive = isJoinedCall,
|
isCallActive = isWidgetLoaded,
|
||||||
isInWidgetMode = isInWidgetMode,
|
isInWidgetMode = isInWidgetMode,
|
||||||
eventSink = { handleEvents(it) },
|
eventSink = { handleEvents(it) },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,7 @@ internal fun IncomingCallScreenPreview() = ElementPreview {
|
||||||
notificationChannelId = "incoming_call",
|
notificationChannelId = "incoming_call",
|
||||||
timestamp = 0L,
|
timestamp = 0L,
|
||||||
textContent = null,
|
textContent = null,
|
||||||
|
expirationTimestamp = 1000L,
|
||||||
),
|
),
|
||||||
onAnswer = {},
|
onAnswer = {},
|
||||||
onCancel = {},
|
onCancel = {},
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
|
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
|
||||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -53,7 +54,7 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.math.min
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the active call state.
|
* Manages the active call state.
|
||||||
|
|
@ -98,6 +99,7 @@ class DefaultActiveCallManager(
|
||||||
private val defaultCurrentCallService: DefaultCurrentCallService,
|
private val defaultCurrentCallService: DefaultCurrentCallService,
|
||||||
private val appForegroundStateService: AppForegroundStateService,
|
private val appForegroundStateService: AppForegroundStateService,
|
||||||
private val imageLoaderHolder: ImageLoaderHolder,
|
private val imageLoaderHolder: ImageLoaderHolder,
|
||||||
|
private val systemClock: SystemClock,
|
||||||
) : ActiveCallManager {
|
) : ActiveCallManager {
|
||||||
private val tag = "DefaultActiveCallManager"
|
private val tag = "DefaultActiveCallManager"
|
||||||
private var timedOutCallJob: Job? = null
|
private var timedOutCallJob: Job? = null
|
||||||
|
|
@ -118,8 +120,20 @@ class DefaultActiveCallManager(
|
||||||
|
|
||||||
override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
|
override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
|
val ringDuration =
|
||||||
|
min(
|
||||||
|
notificationData.expirationTimestamp - systemClock.epochMillis(),
|
||||||
|
ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L
|
||||||
|
)
|
||||||
|
|
||||||
|
if (ringDuration < 0) {
|
||||||
|
// Should already have stopped ringing, ignore.
|
||||||
|
Timber.tag(tag).d("Received timed-out incoming ringing call for room id: ${notificationData.roomId}, cancel ringing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
appForegroundStateService.updateHasRingingCall(true)
|
appForegroundStateService.updateHasRingingCall(true)
|
||||||
Timber.tag(tag).d("Received incoming call for room id: ${notificationData.roomId}")
|
Timber.tag(tag).d("Received incoming call for room id: ${notificationData.roomId}, ringDuration(ms): $ringDuration")
|
||||||
if (activeCall.value != null) {
|
if (activeCall.value != null) {
|
||||||
displayMissedCallNotification(notificationData)
|
displayMissedCallNotification(notificationData)
|
||||||
Timber.tag(tag).w("Already have an active call, ignoring incoming call: $notificationData")
|
Timber.tag(tag).w("Already have an active call, ignoring incoming call: $notificationData")
|
||||||
|
|
@ -138,14 +152,14 @@ class DefaultActiveCallManager(
|
||||||
showIncomingCallNotification(notificationData)
|
showIncomingCallNotification(notificationData)
|
||||||
|
|
||||||
// Wait for the ringing call to time out
|
// Wait for the ringing call to time out
|
||||||
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
|
delay(timeMillis = ringDuration)
|
||||||
incomingCallTimedOut(displayMissedCallNotification = true)
|
incomingCallTimedOut(displayMissedCallNotification = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data
|
// Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data
|
||||||
if (activeWakeLock?.isHeld == false) {
|
if (activeWakeLock?.isHeld == false) {
|
||||||
Timber.tag(tag).d("Acquiring partial wakelock")
|
Timber.tag(tag).d("Acquiring partial wakelock")
|
||||||
activeWakeLock.acquire(ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L)
|
activeWakeLock.acquire(ringDuration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -180,12 +194,22 @@ class DefaultActiveCallManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
|
override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
|
||||||
if (activeCall.value?.callType != callType) {
|
Timber.tag(tag).d("Hung up call: $callType")
|
||||||
|
val currentActiveCall = activeCall.value ?: run {
|
||||||
|
Timber.tag(tag).w("No active call, ignoring hang up")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentActiveCall.callType != callType) {
|
||||||
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
|
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (currentActiveCall.callState is CallState.Ringing) {
|
||||||
Timber.tag(tag).d("Hung up call: $callType")
|
// Decline the call
|
||||||
|
val notificationData = currentActiveCall.callState.notificationData
|
||||||
|
matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull()
|
||||||
|
?.getRoom(notificationData.roomId)
|
||||||
|
?.declineCall(notificationData.eventId)
|
||||||
|
}
|
||||||
|
|
||||||
cancelIncomingCallNotification()
|
cancelIncomingCallNotification()
|
||||||
if (activeWakeLock?.isHeld == true) {
|
if (activeWakeLock?.isHeld == true) {
|
||||||
|
|
@ -226,6 +250,7 @@ class DefaultActiveCallManager(
|
||||||
notificationChannelId = notificationData.notificationChannelId,
|
notificationChannelId = notificationData.notificationChannelId,
|
||||||
timestamp = notificationData.timestamp,
|
timestamp = notificationData.timestamp,
|
||||||
textContent = notificationData.textContent,
|
textContent = notificationData.textContent,
|
||||||
|
expirationTimestamp = notificationData.expirationTimestamp,
|
||||||
) ?: return
|
) ?: return
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
notificationManagerCompat.notify(
|
notificationManagerCompat.notify(
|
||||||
|
|
@ -256,6 +281,43 @@ class DefaultActiveCallManager(
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeRingingCall() {
|
private fun observeRingingCall() {
|
||||||
|
activeCall
|
||||||
|
.filterNotNull()
|
||||||
|
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
|
||||||
|
.flatMapLatest { activeCall ->
|
||||||
|
val callType = activeCall.callType as CallType.RoomCall
|
||||||
|
val ringingInfo = activeCall.callState as CallState.Ringing
|
||||||
|
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
|
||||||
|
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
|
||||||
|
return@flatMapLatest flowOf()
|
||||||
|
}
|
||||||
|
val room = client.getRoom(callType.roomId) ?: run {
|
||||||
|
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
|
||||||
|
return@flatMapLatest flowOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.tag(tag).d("Found room for ringing call: ${room.roomId}")
|
||||||
|
|
||||||
|
// If we have declined from another phone we want to stop ringing.
|
||||||
|
room.subscribeToCallDecline(ringingInfo.notificationData.eventId)
|
||||||
|
.filter { decliner ->
|
||||||
|
Timber.tag(tag).d("Call: $activeCall was declined by $decliner")
|
||||||
|
// only want to listen if the call was declined from another of my sessions,
|
||||||
|
// (we are ringing for an incoming call in a DM)
|
||||||
|
decliner == client.sessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEach { decliner ->
|
||||||
|
Timber.tag(tag).d("Call: $activeCall was declined by user from another session")
|
||||||
|
// Remove the active call and cancel the notification
|
||||||
|
activeCall.value = null
|
||||||
|
if (activeWakeLock?.isHeld == true) {
|
||||||
|
Timber.tag(tag).d("Releasing partial wakelock after call declined from another session")
|
||||||
|
activeWakeLock.release()
|
||||||
|
}
|
||||||
|
cancelIncomingCallNotification()
|
||||||
|
}
|
||||||
|
.launchIn(coroutineScope)
|
||||||
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
|
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
|
||||||
// has joined the call from another session.
|
// has joined the call from another session.
|
||||||
activeCall
|
activeCall
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,14 @@ class DefaultCallWidgetProvider(
|
||||||
val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
|
val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
|
||||||
val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
|
val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
|
||||||
|
|
||||||
val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
|
val roomInfo = room.info()
|
||||||
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted, direct = room.isDm())
|
val isEncrypted = roomInfo.isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
|
||||||
|
val widgetSettings = callWidgetSettingsProvider.provide(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
encrypted = isEncrypted,
|
||||||
|
direct = room.isDm(),
|
||||||
|
hasActiveCall = roomInfo.hasRoomCall,
|
||||||
|
)
|
||||||
val callUrl = room.generateWidgetWebViewUrl(
|
val callUrl = room.generateWidgetWebViewUrl(
|
||||||
widgetSettings = widgetSettings,
|
widgetSettings = widgetSettings,
|
||||||
clientId = clientId,
|
clientId = clientId,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import kotlinx.serialization.json.Json
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class manages the audio devices for a WebView.
|
* This class manages the audio devices for a WebView.
|
||||||
|
|
@ -246,7 +246,6 @@ class WebViewAudioManager(
|
||||||
private fun registerWebViewDeviceSelectedCallback() {
|
private fun registerWebViewDeviceSelectedCallback() {
|
||||||
val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge(
|
val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge(
|
||||||
onAudioDeviceSelected = { selectedDeviceId ->
|
onAudioDeviceSelected = { selectedDeviceId ->
|
||||||
Timber.d("Audio device selected in webview, id: $selectedDeviceId")
|
|
||||||
previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId }
|
previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId }
|
||||||
audioManager.selectAudioDevice(selectedDeviceId)
|
audioManager.selectAudioDevice(selectedDeviceId)
|
||||||
},
|
},
|
||||||
|
|
@ -254,7 +253,7 @@ class WebViewAudioManager(
|
||||||
coroutineScope.launch(Dispatchers.Main) {
|
coroutineScope.launch(Dispatchers.Main) {
|
||||||
// Even with the callback, it seems like starting the audio takes a bit on the webview side,
|
// Even with the callback, it seems like starting the audio takes a bit on the webview side,
|
||||||
// so we add an extra delay here to make sure it's ready
|
// so we add an extra delay here to make sure it's ready
|
||||||
delay(500.milliseconds)
|
delay(2.seconds)
|
||||||
|
|
||||||
// Calling this ahead of time makes the default audio device to not use the right audio stream
|
// Calling this ahead of time makes the default audio device to not use the right audio stream
|
||||||
setAvailableAudioDevices()
|
setAvailableAudioDevices()
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ class DefaultElementCallEntryPointTest {
|
||||||
senderName = "senderName",
|
senderName = "senderName",
|
||||||
avatarUrl = "avatarUrl",
|
avatarUrl = "avatarUrl",
|
||||||
timestamp = 0,
|
timestamp = 0,
|
||||||
|
expirationTimestamp = 0,
|
||||||
notificationChannelId = "notificationChannelId",
|
notificationChannelId = "notificationChannelId",
|
||||||
textContent = "textContent",
|
textContent = "textContent",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ class RingingCallNotificationCreatorTest {
|
||||||
roomAvatarUrl = "https://example.com/avatar.jpg",
|
roomAvatarUrl = "https://example.com/avatar.jpg",
|
||||||
notificationChannelId = "channelId",
|
notificationChannelId = "channelId",
|
||||||
timestamp = 0L,
|
timestamp = 0L,
|
||||||
|
expirationTimestamp = 20L,
|
||||||
textContent = "textContent",
|
textContent = "textContent",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - a received 'joined' action makes the call to be active`() = runTest {
|
fun `present - a received 'content loaded' action makes the call to be active`() = runTest {
|
||||||
val navigator = FakeCallScreenNavigator()
|
val navigator = FakeCallScreenNavigator()
|
||||||
val widgetDriver = FakeMatrixWidgetDriver()
|
val widgetDriver = FakeMatrixWidgetDriver()
|
||||||
val presenter = createCallScreenPresenter(
|
val presenter = createCallScreenPresenter(
|
||||||
|
|
@ -238,7 +238,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
messageInterceptor.givenInterceptedMessage(
|
messageInterceptor.givenInterceptedMessage(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"action":"io.element.join",
|
"action":"content_loaded",
|
||||||
"api":"fromWidget",
|
"api":"fromWidget",
|
||||||
"widgetId":"1",
|
"widgetId":"1",
|
||||||
"requestId":"1"
|
"requestId":"1"
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,16 @@ import io.element.android.features.call.test.aCallNotificationData
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
|
|
@ -36,8 +39,12 @@ import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolde
|
||||||
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
|
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
|
||||||
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
|
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
|
||||||
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||||
|
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
|
||||||
|
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.plantTestTimber
|
||||||
|
import io.mockk.coVerify
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -164,6 +171,102 @@ class DefaultActiveCallManagerTest {
|
||||||
verify { notificationManagerCompat.cancel(notificationId) }
|
verify { notificationManagerCompat.cancel(notificationId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Decline event - Hangup on a ringing call should send a decline event`() = runTest {
|
||||||
|
setupShadowPowerManager()
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
|
||||||
|
val room = mockk<JoinedRoom>(relaxed = true)
|
||||||
|
|
||||||
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
}
|
||||||
|
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
|
||||||
|
|
||||||
|
val manager = createActiveCallManager(
|
||||||
|
matrixClientProvider = clientProvider,
|
||||||
|
notificationManagerCompat = notificationManagerCompat
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
|
||||||
|
manager.registerIncomingCall(notificationData)
|
||||||
|
|
||||||
|
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
room.declineCall(notificationEventId = notificationData.eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `Decline event - Declining from another session should stop ringing`() = runTest {
|
||||||
|
setupShadowPowerManager()
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
|
||||||
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
}
|
||||||
|
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
|
||||||
|
|
||||||
|
val manager = createActiveCallManager(
|
||||||
|
matrixClientProvider = clientProvider,
|
||||||
|
notificationManagerCompat = notificationManagerCompat
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
|
||||||
|
manager.registerIncomingCall(notificationData)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
// Simulate declined from other session
|
||||||
|
room.baseRoom.givenDecliner(matrixClient.sessionId, notificationData.eventId)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isFalse()
|
||||||
|
|
||||||
|
verify { notificationManagerCompat.cancel(notificationId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `Decline event - Should ignore decline for other notification events`() = runTest {
|
||||||
|
plantTestTimber()
|
||||||
|
setupShadowPowerManager()
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
|
||||||
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
}
|
||||||
|
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
|
||||||
|
|
||||||
|
val manager = createActiveCallManager(
|
||||||
|
matrixClientProvider = clientProvider,
|
||||||
|
notificationManagerCompat = notificationManagerCompat
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
|
||||||
|
manager.registerIncomingCall(notificationData)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
// Simulate declined for another notification event
|
||||||
|
room.baseRoom.givenDecliner(matrixClient.sessionId, AN_EVENT_ID_2)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isTrue()
|
||||||
|
|
||||||
|
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
|
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
|
||||||
setupShadowPowerManager()
|
setupShadowPowerManager()
|
||||||
|
|
@ -267,6 +370,83 @@ class DefaultActiveCallManagerTest {
|
||||||
assertThat(manager.activeCall.value).isNotNull()
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `IncomingCall - rings no longer than expiration time`() = runTest {
|
||||||
|
setupShadowPowerManager()
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
val clock = FakeSystemClock()
|
||||||
|
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat, systemClock = clock)
|
||||||
|
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isFalse()
|
||||||
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
|
||||||
|
val eventTimestamp = A_FAKE_TIMESTAMP
|
||||||
|
// The call should not ring more than 30 seconds after the initial event was sent
|
||||||
|
val expirationTimestamp = eventTimestamp + 30_000
|
||||||
|
|
||||||
|
val callNotificationData = aCallNotificationData(
|
||||||
|
timestamp = eventTimestamp,
|
||||||
|
expirationTimestamp = expirationTimestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
// suppose it took 10s to be notified
|
||||||
|
clock.epochMillisResult = eventTimestamp + 10_000
|
||||||
|
manager.registerIncomingCall(callNotificationData)
|
||||||
|
|
||||||
|
assertThat(manager.activeCall.value).isEqualTo(
|
||||||
|
ActiveCall(
|
||||||
|
callType = CallType.RoomCall(
|
||||||
|
sessionId = callNotificationData.sessionId,
|
||||||
|
roomId = callNotificationData.roomId,
|
||||||
|
),
|
||||||
|
callState = CallState.Ringing(callNotificationData)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isTrue()
|
||||||
|
verify { notificationManagerCompat.notify(notificationId, any()) }
|
||||||
|
|
||||||
|
// advance by 21s it should have stopped ringing
|
||||||
|
advanceTimeBy(21_000)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
verify { notificationManagerCompat.cancel(any()) }
|
||||||
|
}
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `IncomingCall - ignore expired ring lifetime`() = runTest {
|
||||||
|
setupShadowPowerManager()
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
val clock = FakeSystemClock()
|
||||||
|
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat, systemClock = clock)
|
||||||
|
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isFalse()
|
||||||
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
|
||||||
|
val eventTimestamp = A_FAKE_TIMESTAMP
|
||||||
|
// The call should not ring more than 30 seconds after the initial event was sent
|
||||||
|
val expirationTimestamp = eventTimestamp + 30_000
|
||||||
|
|
||||||
|
val callNotificationData = aCallNotificationData(
|
||||||
|
timestamp = eventTimestamp,
|
||||||
|
expirationTimestamp = expirationTimestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
// suppose it took 35s to be notified
|
||||||
|
clock.epochMillisResult = eventTimestamp + 35_000
|
||||||
|
manager.registerIncomingCall(callNotificationData)
|
||||||
|
|
||||||
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isFalse()
|
||||||
|
verify(exactly = 0) { notificationManagerCompat.notify(notificationId, any()) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupShadowPowerManager() {
|
private fun setupShadowPowerManager() {
|
||||||
shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService<PowerManager>()).apply {
|
shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService<PowerManager>()).apply {
|
||||||
setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
|
setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
|
||||||
|
|
@ -277,6 +457,7 @@ class DefaultActiveCallManagerTest {
|
||||||
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||||
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
|
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
|
||||||
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
|
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
|
||||||
|
systemClock: FakeSystemClock = FakeSystemClock(),
|
||||||
) = DefaultActiveCallManager(
|
) = DefaultActiveCallManager(
|
||||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||||
coroutineScope = backgroundScope,
|
coroutineScope = backgroundScope,
|
||||||
|
|
@ -292,5 +473,6 @@ class DefaultActiveCallManagerTest {
|
||||||
defaultCurrentCallService = DefaultCurrentCallService(),
|
defaultCurrentCallService = DefaultCurrentCallService(),
|
||||||
appForegroundStateService = FakeAppForegroundStateService(),
|
appForegroundStateService = FakeAppForegroundStateService(),
|
||||||
imageLoaderHolder = FakeImageLoaderHolder(),
|
imageLoaderHolder = FakeImageLoaderHolder(),
|
||||||
|
systemClock = systemClock,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ fun aCallNotificationData(
|
||||||
avatarUrl: String? = AN_AVATAR_URL,
|
avatarUrl: String? = AN_AVATAR_URL,
|
||||||
notificationChannelId: String = "channel_id",
|
notificationChannelId: String = "channel_id",
|
||||||
timestamp: Long = 0L,
|
timestamp: Long = 0L,
|
||||||
|
expirationTimestamp: Long = 30_000L,
|
||||||
textContent: String? = null,
|
textContent: String? = null,
|
||||||
): CallNotificationData = CallNotificationData(
|
): CallNotificationData = CallNotificationData(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
|
|
@ -41,5 +42,6 @@ fun aCallNotificationData(
|
||||||
avatarUrl = avatarUrl,
|
avatarUrl = avatarUrl,
|
||||||
notificationChannelId = notificationChannelId,
|
notificationChannelId = notificationChannelId,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
|
expirationTimestamp = expirationTimestamp,
|
||||||
textContent = textContent,
|
textContent = textContent,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class FakeElementCallEntryPoint(
|
||||||
senderName: String?,
|
senderName: String?,
|
||||||
avatarUrl: String?,
|
avatarUrl: String?,
|
||||||
timestamp: Long,
|
timestamp: Long,
|
||||||
|
expirationTimestamp: Long,
|
||||||
notificationChannelId: String,
|
notificationChannelId: String,
|
||||||
textContent: String?,
|
textContent: String?,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
|
||||||
interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
|
fun interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
|
||||||
fun builder(parentNode: Node, buildContext: BuildContext): Builder
|
fun builder(parentNode: Node, buildContext: BuildContext): Builder
|
||||||
|
|
||||||
interface Builder {
|
interface Builder {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2025 New Vector Ltd.
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
|
@ -37,15 +38,7 @@ dependencies {
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
|
|
||||||
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(libs.test.junit)
|
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,16 +37,7 @@ class ChangeRolesNode(
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
private val presenter = presenterFactory.create(inputs.listType.toRoomMemberRole())
|
||||||
private val presenter = presenterFactory.run {
|
|
||||||
val role = when (inputs.listType) {
|
|
||||||
ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin
|
|
||||||
ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator
|
|
||||||
ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false)
|
|
||||||
}
|
|
||||||
create(role)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val stateFlow = launchMolecule { presenter.present() }
|
private val stateFlow = launchMolecule { presenter.present() }
|
||||||
|
|
||||||
suspend fun waitForRoleChanged() {
|
suspend fun waitForRoleChanged() {
|
||||||
|
|
@ -63,3 +54,9 @@ class ChangeRolesNode(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun ChangeRoomMemberRolesListType.toRoomMemberRole() = when (this) {
|
||||||
|
ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin
|
||||||
|
ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator
|
||||||
|
ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ class ChangeRolesPresenter(
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
) : Presenter<ChangeRolesState> {
|
) : Presenter<ChangeRolesState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
fun interface Factory {
|
||||||
fun create(role: RoomMember.Role): ChangeRolesPresenter
|
fun create(role: RoomMember.Role): ChangeRolesPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,16 +39,13 @@ class ChangeRoomMemberRolesRootNode(
|
||||||
roomComponentFactory: RoomComponentFactory,
|
roomComponentFactory: RoomComponentFactory,
|
||||||
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
|
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
|
||||||
navModel = PermanentNavModel(
|
navModel = PermanentNavModel(
|
||||||
navTargets = setOf(NavTarget.Root),
|
navTargets = setOf(NavTarget),
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
),
|
),
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
), DependencyInjectionGraphOwner, ChangeRoomMemberRolesEntryPoint.NodeProxy {
|
), DependencyInjectionGraphOwner, ChangeRoomMemberRolesEntryPoint.NodeProxy {
|
||||||
sealed interface NavTarget : Parcelable {
|
@Parcelize object NavTarget : Parcelable
|
||||||
@Parcelize
|
|
||||||
object Root : NavTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val joinedRoom: JoinedRoom,
|
val joinedRoom: JoinedRoom,
|
||||||
|
|
@ -60,14 +57,10 @@ class ChangeRoomMemberRolesRootNode(
|
||||||
override val graph = roomComponentFactory.create(inputs.joinedRoom)
|
override val graph = roomComponentFactory.create(inputs.joinedRoom)
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return createNode<ChangeRolesNode>(
|
||||||
NavTarget.Root -> {
|
buildContext = buildContext,
|
||||||
createNode<ChangeRolesNode>(
|
plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
|
||||||
buildContext = buildContext,
|
)
|
||||||
plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_room_change_permissions_administrators">"Gweinyddwyr yn unig"</string>
|
<string name="screen_room_change_permissions_administrators">"Gweinyddwyr yn unig"</string>
|
||||||
<string name="screen_room_change_permissions_ban_people">"Gwahardd pobl"</string>
|
<string name="screen_room_change_permissions_ban_people">"Gwahardd pobl"</string>
|
||||||
<string name="screen_room_change_permissions_delete_messages">"Dileu negeseuon"</string>
|
<string name="screen_room_change_permissions_delete_messages">"Tynnu negeseuon"</string>
|
||||||
<string name="screen_room_change_permissions_everyone">"Pawb"</string>
|
<string name="screen_room_change_permissions_everyone">"Pawb"</string>
|
||||||
<string name="screen_room_change_permissions_invite_people">"Gwahodd pobl a derbyn ceisiadau i ymuno"</string>
|
<string name="screen_room_change_permissions_invite_people">"Gwahodd pobl a derbyn ceisiadau i ymuno"</string>
|
||||||
<string name="screen_room_change_permissions_member_moderation">"Cymedroli aelodau"</string>
|
<string name="screen_room_change_permissions_member_moderation">"Cymedroli aelodau"</string>
|
||||||
|
|
@ -17,13 +17,17 @@
|
||||||
<string name="screen_room_change_role_administrators_title">"Golygu Gweinyddwyr"</string>
|
<string name="screen_room_change_role_administrators_title">"Golygu Gweinyddwyr"</string>
|
||||||
<string name="screen_room_change_role_confirm_add_admin_description">"Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych chi\'n hyrwyddo\'r defnyddiwr i gael yr un lefel pŵer â chi."</string>
|
<string name="screen_room_change_role_confirm_add_admin_description">"Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych chi\'n hyrwyddo\'r defnyddiwr i gael yr un lefel pŵer â chi."</string>
|
||||||
<string name="screen_room_change_role_confirm_add_admin_title">"Ychwanegu Gweinyddwr?"</string>
|
<string name="screen_room_change_role_confirm_add_admin_title">"Ychwanegu Gweinyddwr?"</string>
|
||||||
|
<string name="screen_room_change_role_confirm_change_owners_description">"Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych yn trosglwyddo\'r berchnogaeth i\'r defnyddwyr a ddewiswyd. Unwaith y byddwch yn gadael bydd hyn yn barhaol."</string>
|
||||||
|
<string name="screen_room_change_role_confirm_change_owners_title">"Trosglwyddo perchnogaeth?"</string>
|
||||||
<string name="screen_room_change_role_confirm_demote_self_action">"Gostwng"</string>
|
<string name="screen_room_change_role_confirm_demote_self_action">"Gostwng"</string>
|
||||||
<string name="screen_room_change_role_confirm_demote_self_description">"Fyddwch chi ddim yn gallu dadwneud y newid hwn gan eich bod yn israddio eich hun, os mai chi yw\'r defnyddiwr breintiedig olaf yn yr ystafell bydd yn amhosibl adennill breintiau."</string>
|
<string name="screen_room_change_role_confirm_demote_self_description">"Fyddwch chi ddim yn gallu dadwneud y newid hwn gan eich bod yn israddio eich hun, os mai chi yw\'r defnyddiwr breintiedig olaf yn yr ystafell bydd yn amhosibl adennill breintiau."</string>
|
||||||
<string name="screen_room_change_role_confirm_demote_self_title">"Israddio eich hun?"</string>
|
<string name="screen_room_change_role_confirm_demote_self_title">"Israddio eich hun?"</string>
|
||||||
<string name="screen_room_change_role_invited_member_name">"%1$s (Yn aros)"</string>
|
<string name="screen_room_change_role_invited_member_name">"%1$s (Yn aros)"</string>
|
||||||
<string name="screen_room_change_role_invited_member_name_android">"Yn aros"</string>
|
<string name="screen_room_change_role_invited_member_name_android">"Yn aros"</string>
|
||||||
<string name="screen_room_change_role_moderators_admin_section_footer">"Mae gan weinyddwyr freintiau cymedrolwr yn awtomatig"</string>
|
<string name="screen_room_change_role_moderators_admin_section_footer">"Mae gan weinyddwyr freintiau cymedrolwr yn awtomatig"</string>
|
||||||
|
<string name="screen_room_change_role_moderators_owner_section_footer">"Mae gan berchnogion freintiau gweinyddwr yn awtomatig."</string>
|
||||||
<string name="screen_room_change_role_moderators_title">"Golygu Cymedrolwyr"</string>
|
<string name="screen_room_change_role_moderators_title">"Golygu Cymedrolwyr"</string>
|
||||||
|
<string name="screen_room_change_role_owners_title">"Dewiswch Berchnogion"</string>
|
||||||
<string name="screen_room_change_role_section_administrators">"Gweinyddwyr"</string>
|
<string name="screen_room_change_role_section_administrators">"Gweinyddwyr"</string>
|
||||||
<string name="screen_room_change_role_section_moderators">"Cymedrolwyr"</string>
|
<string name="screen_room_change_role_section_moderators">"Cymedrolwyr"</string>
|
||||||
<string name="screen_room_change_role_section_users">"Aelodau"</string>
|
<string name="screen_room_change_role_section_users">"Aelodau"</string>
|
||||||
|
|
@ -48,15 +52,18 @@
|
||||||
<string name="screen_room_member_list_pending_header_title">"Dan ystyriaeth"</string>
|
<string name="screen_room_member_list_pending_header_title">"Dan ystyriaeth"</string>
|
||||||
<string name="screen_room_member_list_role_administrator">"Gweinyddwr"</string>
|
<string name="screen_room_member_list_role_administrator">"Gweinyddwr"</string>
|
||||||
<string name="screen_room_member_list_role_moderator">"Cymedrolwr"</string>
|
<string name="screen_room_member_list_role_moderator">"Cymedrolwr"</string>
|
||||||
|
<string name="screen_room_member_list_role_owner">"Perchennog"</string>
|
||||||
<string name="screen_room_member_list_room_members_header_title">"Aelodau\'r ystafell"</string>
|
<string name="screen_room_member_list_room_members_header_title">"Aelodau\'r ystafell"</string>
|
||||||
<string name="screen_room_member_list_unbanning_user">"Dad-wahardd %1$s"</string>
|
<string name="screen_room_member_list_unbanning_user">"Dad-wahardd %1$s"</string>
|
||||||
<string name="screen_room_roles_and_permissions_admins">"Gweinyddwyr"</string>
|
<string name="screen_room_roles_and_permissions_admins">"Gweinyddwyr"</string>
|
||||||
|
<string name="screen_room_roles_and_permissions_admins_and_owners">"Gweinyddwyr a pherchnogion"</string>
|
||||||
<string name="screen_room_roles_and_permissions_change_my_role">"Newid fy rôl"</string>
|
<string name="screen_room_roles_and_permissions_change_my_role">"Newid fy rôl"</string>
|
||||||
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Israddio aelod"</string>
|
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Israddio aelod"</string>
|
||||||
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Israddio cymedrolwr"</string>
|
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Israddio cymedrolwr"</string>
|
||||||
<string name="screen_room_roles_and_permissions_member_moderation">"Cymedroli aelodau"</string>
|
<string name="screen_room_roles_and_permissions_member_moderation">"Cymedroli aelodau"</string>
|
||||||
<string name="screen_room_roles_and_permissions_messages_and_content">"Negeseuon a chynnwys"</string>
|
<string name="screen_room_roles_and_permissions_messages_and_content">"Negeseuon a chynnwys"</string>
|
||||||
<string name="screen_room_roles_and_permissions_moderators">"Cymedrolwyr"</string>
|
<string name="screen_room_roles_and_permissions_moderators">"Cymedrolwyr"</string>
|
||||||
|
<string name="screen_room_roles_and_permissions_owners">"Perchnogion"</string>
|
||||||
<string name="screen_room_roles_and_permissions_permissions_header">"Caniatâd"</string>
|
<string name="screen_room_roles_and_permissions_permissions_header">"Caniatâd"</string>
|
||||||
<string name="screen_room_roles_and_permissions_reset">"Ailosod caniatâd"</string>
|
<string name="screen_room_roles_and_permissions_reset">"Ailosod caniatâd"</string>
|
||||||
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Ar ôl i chi ailosod caniatâd, byddwch yn colli\'r gosodiadau cyfredol."</string>
|
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Ar ôl i chi ailosod caniatâd, byddwch yn colli\'r gosodiadau cyfredol."</string>
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,12 @@
|
||||||
<string name="screen_room_change_permissions_send_messages">"Viestien lähettäminen"</string>
|
<string name="screen_room_change_permissions_send_messages">"Viestien lähettäminen"</string>
|
||||||
<string name="screen_room_change_role_administrators_title">"Muokkaa ylläpitäjiä"</string>
|
<string name="screen_room_change_role_administrators_title">"Muokkaa ylläpitäjiä"</string>
|
||||||
<string name="screen_room_change_role_confirm_add_admin_description">"Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä."</string>
|
<string name="screen_room_change_role_confirm_add_admin_description">"Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä."</string>
|
||||||
<string name="screen_room_change_role_confirm_add_admin_title">"Lisää ylläpitäjä?"</string>
|
<string name="screen_room_change_role_confirm_add_admin_title">"Lisätäänkö ylläpitäjä?"</string>
|
||||||
<string name="screen_room_change_role_confirm_change_owners_description">"Et voi kumota tätä toimintoa. Olet siirtämässä omistajuuden valituille käyttäjille. Kun poistut, muutos on pysyvä."</string>
|
<string name="screen_room_change_role_confirm_change_owners_description">"Et voi kumota tätä toimintoa. Olet siirtämässä omistajuuden valituille käyttäjille. Kun poistut, muutos on pysyvä."</string>
|
||||||
<string name="screen_room_change_role_confirm_change_owners_title">"Siirretäänkö omistajuus?"</string>
|
<string name="screen_room_change_role_confirm_change_owners_title">"Siirretäänkö omistajuus?"</string>
|
||||||
<string name="screen_room_change_role_confirm_demote_self_action">"Alenna"</string>
|
<string name="screen_room_change_role_confirm_demote_self_action">"Alenna"</string>
|
||||||
<string name="screen_room_change_role_confirm_demote_self_description">"Et voi perua tätä muutosta, koska olet alentamassa itseäsi. Jos olet viimeinen oikeutettu henkilö tässä huoneessa, oikeuksia ei voi enää saada takaisin."</string>
|
<string name="screen_room_change_role_confirm_demote_self_description">"Et voi perua tätä muutosta, koska olet alentamassa itseäsi. Jos olet viimeinen oikeutettu henkilö tässä huoneessa, oikeuksia ei voi enää saada takaisin."</string>
|
||||||
<string name="screen_room_change_role_confirm_demote_self_title">"Alenna itsesi?"</string>
|
<string name="screen_room_change_role_confirm_demote_self_title">"Haluatko alentaa itsesi?"</string>
|
||||||
<string name="screen_room_change_role_invited_member_name">"%1$s (Kutsuttu)"</string>
|
<string name="screen_room_change_role_invited_member_name">"%1$s (Kutsuttu)"</string>
|
||||||
<string name="screen_room_change_role_invited_member_name_android">"(Kutsuttu)"</string>
|
<string name="screen_room_change_role_invited_member_name_android">"(Kutsuttu)"</string>
|
||||||
<string name="screen_room_change_role_moderators_admin_section_footer">"Ylläpitäjillä on automaattisesti valvojan oikeudet"</string>
|
<string name="screen_room_change_role_moderators_admin_section_footer">"Ylläpitäjillä on automaattisesti valvojan oikeudet"</string>
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
<string name="screen_room_change_role_section_moderators">"Valvojat"</string>
|
<string name="screen_room_change_role_section_moderators">"Valvojat"</string>
|
||||||
<string name="screen_room_change_role_section_users">"Jäsenet"</string>
|
<string name="screen_room_change_role_section_users">"Jäsenet"</string>
|
||||||
<string name="screen_room_change_role_unsaved_changes_description">"Sinulla on tallentamattomia muutoksia"</string>
|
<string name="screen_room_change_role_unsaved_changes_description">"Sinulla on tallentamattomia muutoksia"</string>
|
||||||
<string name="screen_room_change_role_unsaved_changes_title">"Tallenna muutokset?"</string>
|
<string name="screen_room_change_role_unsaved_changes_title">"Tallennetaanko muutokset?"</string>
|
||||||
<string name="screen_room_member_list_banned_empty">"Tässä huoneessa ei ole porttikieltoja"</string>
|
<string name="screen_room_member_list_banned_empty">"Tässä huoneessa ei ole porttikieltoja"</string>
|
||||||
<plurals name="screen_room_member_list_header_title">
|
<plurals name="screen_room_member_list_header_title">
|
||||||
<item quantity="one">"%1$d henkilö"</item>
|
<item quantity="one">"%1$d henkilö"</item>
|
||||||
|
|
|
||||||
|
|
@ -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.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.collections.plus
|
|
||||||
|
|
||||||
class ChangeRolesPresenterTest {
|
class ChangeRolesPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -556,18 +555,18 @@ class ChangeRolesPresenterTest {
|
||||||
users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toPersistentMap()
|
users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toPersistentMap()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private fun TestScope.createChangeRolesPresenter(
|
|
||||||
role: RoomMember.Role = RoomMember.Role.Admin,
|
internal fun TestScope.createChangeRolesPresenter(
|
||||||
room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
|
role: RoomMember.Role = RoomMember.Role.Admin,
|
||||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
|
||||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
): ChangeRolesPresenter {
|
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||||
return ChangeRolesPresenter(
|
): ChangeRolesPresenter {
|
||||||
role = role,
|
return ChangeRolesPresenter(
|
||||||
room = room,
|
role = role,
|
||||||
dispatchers = dispatchers,
|
room = room,
|
||||||
analyticsService = analyticsService,
|
dispatchers = dispatchers,
|
||||||
)
|
analyticsService = analyticsService,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2022-2024 New Vector Ltd.
|
* Copyright 2022-2024 New Vector Ltd.
|
||||||
|
|
@ -43,13 +44,7 @@ dependencies {
|
||||||
implementation(projects.features.invitepeople.api)
|
implementation(projects.features.invitepeople.api)
|
||||||
api(projects.features.createroom.api)
|
api(projects.features.createroom.api)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(libs.test.mockk)
|
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.mediapickers.test)
|
testImplementation(projects.libraries.mediapickers.test)
|
||||||
|
|
@ -58,7 +53,4 @@ dependencies {
|
||||||
testImplementation(projects.libraries.usersearch.test)
|
testImplementation(projects.libraries.usersearch.test)
|
||||||
testImplementation(projects.features.startchat.test)
|
testImplementation(projects.features.startchat.test)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2024 New Vector Ltd.
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
|
@ -34,14 +35,6 @@ dependencies {
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
api(projects.features.deactivation.api)
|
api(projects.features.deactivation.api)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_deactivate_account_confirmation_dialog_content">"Confirme que pretende desativar a sua conta. Esta ação não pode ser desfeita."</string>
|
<string name="screen_deactivate_account_confirmation_dialog_content">"Confirma que pretendes desativar a tua conta. Esta ação não pode ser desfeita."</string>
|
||||||
<string name="screen_deactivate_account_delete_all_messages">"Eliminar todas as minhas mensagens"</string>
|
<string name="screen_deactivate_account_delete_all_messages">"Eliminar todas as minhas mensagens"</string>
|
||||||
<string name="screen_deactivate_account_delete_all_messages_notice">"Aviso: futuros usuários podem ver conversas incompletas."</string>
|
<string name="screen_deactivate_account_delete_all_messages_notice">"Aviso: futuros usuários podem ver conversas incompletas."</string>
|
||||||
<string name="screen_deactivate_account_description">"A desativação da sua conta é %1$s, irá:"</string>
|
<string name="screen_deactivate_account_description">"A desativação da sua conta é %1$s, irá:"</string>
|
||||||
|
|
|
||||||
|
|
@ -148,10 +148,10 @@ class AccountDeactivationPresenterTest {
|
||||||
assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized)
|
assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPresenter(
|
|
||||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
|
||||||
) = AccountDeactivationPresenter(
|
|
||||||
matrixClient = matrixClient,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun createPresenter(
|
||||||
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
|
) = AccountDeactivationPresenter(
|
||||||
|
matrixClient = matrixClient,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2024 New Vector Ltd.
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
|
@ -22,8 +23,6 @@ dependencies {
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|
||||||
testImplementation(libs.coroutines.test)
|
testCommonDependencies(libs)
|
||||||
testImplementation(libs.test.junit)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,6 @@
|
||||||
|
|
||||||
package io.element.android.features.ftue.api
|
package io.element.android.features.ftue.api
|
||||||
|
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
|
||||||
import com.bumble.appyx.core.node.Node
|
|
||||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
|
||||||
|
|
||||||
interface FtueEntryPoint : FeatureEntryPoint {
|
interface FtueEntryPoint : SimpleFeatureEntryPoint
|
||||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
|
||||||
|
|
||||||
interface NodeBuilder {
|
|
||||||
fun build(): Node
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
interface FtueService {
|
interface FtueService {
|
||||||
/** The current state of the FTUE. */
|
/** The current state of the FTUE. */
|
||||||
val state: StateFlow<FtueState>
|
val state: StateFlow<FtueState>
|
||||||
|
|
||||||
/** Reset the FTUE state. */
|
|
||||||
suspend fun reset()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The state of the FTUE. */
|
/** The state of the FTUE. */
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
* Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
|
@ -46,14 +47,7 @@ dependencies {
|
||||||
implementation(projects.services.toolbox.api)
|
implementation(projects.services.toolbox.api)
|
||||||
implementation(projects.appconfig)
|
implementation(projects.appconfig)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.services.analytics.noop)
|
testImplementation(projects.services.analytics.noop)
|
||||||
|
|
@ -61,5 +55,4 @@ dependencies {
|
||||||
testImplementation(projects.libraries.preferences.test)
|
testImplementation(projects.libraries.preferences.test)
|
||||||
testImplementation(projects.features.lockscreen.test)
|
testImplementation(projects.features.lockscreen.test)
|
||||||
testImplementation(projects.services.toolbox.test)
|
testImplementation(projects.services.toolbox.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ package io.element.android.features.ftue.impl
|
||||||
|
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
|
|
@ -19,13 +18,7 @@ import io.element.android.libraries.architecture.createNode
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
@Inject
|
@Inject
|
||||||
class DefaultFtueEntryPoint : FtueEntryPoint {
|
class DefaultFtueEntryPoint : FtueEntryPoint {
|
||||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder {
|
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
|
||||||
val plugins = ArrayList<Plugin>()
|
return parentNode.createNode<FtueFlowNode>(buildContext)
|
||||||
|
|
||||||
return object : FtueEntryPoint.NodeBuilder {
|
|
||||||
override fun build(): Node {
|
|
||||||
return parentNode.createNode<FtueFlowNode>(buildContext, plugins)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.bumble.appyx.core.lifecycle.subscribe
|
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
|
@ -30,18 +29,16 @@ import io.element.android.features.ftue.impl.notifications.NotificationsOptInNod
|
||||||
import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
|
import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
|
||||||
import io.element.android.features.ftue.impl.state.DefaultFtueService
|
import io.element.android.features.ftue.impl.state.DefaultFtueService
|
||||||
import io.element.android.features.ftue.impl.state.FtueStep
|
import io.element.android.features.ftue.impl.state.FtueStep
|
||||||
|
import io.element.android.features.ftue.impl.state.InternalFtueState
|
||||||
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackView
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
|
|
@ -49,9 +46,8 @@ import kotlinx.parcelize.Parcelize
|
||||||
class FtueFlowNode(
|
class FtueFlowNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val ftueState: DefaultFtueService,
|
private val defaultFtueService: DefaultFtueService,
|
||||||
private val analyticsEntryPoint: AnalyticsEntryPoint,
|
private val analyticsEntryPoint: AnalyticsEntryPoint,
|
||||||
private val analyticsService: AnalyticsService,
|
|
||||||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||||
) : BaseFlowNode<FtueFlowNode.NavTarget>(
|
) : BaseFlowNode<FtueFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -80,19 +76,11 @@ class FtueFlowNode(
|
||||||
|
|
||||||
override fun onBuilt() {
|
override fun onBuilt() {
|
||||||
super.onBuilt()
|
super.onBuilt()
|
||||||
|
defaultFtueService.ftueStepStateFlow
|
||||||
lifecycle.subscribe(onCreate = {
|
.filterIsInstance(InternalFtueState.Incomplete::class)
|
||||||
moveToNextStepIfNeeded()
|
.onEach {
|
||||||
})
|
showStep(it.nextStep)
|
||||||
|
}
|
||||||
analyticsService.didAskUserConsentFlow
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.onEach { moveToNextStepIfNeeded() }
|
|
||||||
.launchIn(lifecycleScope)
|
|
||||||
|
|
||||||
ftueState.isVerificationStatusKnown
|
|
||||||
.filter { it }
|
|
||||||
.onEach { moveToNextStepIfNeeded() }
|
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +92,7 @@ class FtueFlowNode(
|
||||||
is NavTarget.SessionVerification -> {
|
is NavTarget.SessionVerification -> {
|
||||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||||
override fun onDone() {
|
override fun onDone() {
|
||||||
moveToNextStepIfNeeded()
|
defaultFtueService.onUserCompletedSessionVerification()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
|
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
|
||||||
|
|
@ -112,7 +100,7 @@ class FtueFlowNode(
|
||||||
NavTarget.NotificationsOptIn -> {
|
NavTarget.NotificationsOptIn -> {
|
||||||
val callback = object : NotificationsOptInNode.Callback {
|
val callback = object : NotificationsOptInNode.Callback {
|
||||||
override fun onNotificationsOptInFinished() {
|
override fun onNotificationsOptInFinished() {
|
||||||
moveToNextStepIfNeeded()
|
defaultFtueService.updateFtueStep()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
|
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
|
||||||
|
|
@ -123,7 +111,7 @@ class FtueFlowNode(
|
||||||
NavTarget.LockScreenSetup -> {
|
NavTarget.LockScreenSetup -> {
|
||||||
val callback = object : LockScreenEntryPoint.Callback {
|
val callback = object : LockScreenEntryPoint.Callback {
|
||||||
override fun onSetupDone() {
|
override fun onSetupDone() {
|
||||||
moveToNextStepIfNeeded()
|
defaultFtueService.updateFtueStep()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
|
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
|
||||||
|
|
@ -133,8 +121,8 @@ class FtueFlowNode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun moveToNextStepIfNeeded() = lifecycleScope.launch {
|
private fun showStep(ftueStep: FtueStep) {
|
||||||
when (ftueState.getNextStep()) {
|
when (ftueStep) {
|
||||||
FtueStep.WaitingForInitialState -> {
|
FtueStep.WaitingForInitialState -> {
|
||||||
backstack.newRoot(NavTarget.Placeholder)
|
backstack.newRoot(NavTarget.Placeholder)
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +138,6 @@ class FtueFlowNode(
|
||||||
FtueStep.LockscreenSetup -> {
|
FtueStep.LockscreenSetup -> {
|
||||||
backstack.newRoot(NavTarget.LockScreenSetup)
|
backstack.newRoot(NavTarget.LockScreenSetup)
|
||||||
}
|
}
|
||||||
null -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,13 @@ package io.element.android.features.ftue.impl.state
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metro.SingleIn
|
import dev.zacsweers.metro.SingleIn
|
||||||
import io.element.android.features.ftue.api.state.FtueService
|
import io.element.android.features.ftue.api.state.FtueService
|
||||||
import io.element.android.features.ftue.api.state.FtueState
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
import io.element.android.features.lockscreen.api.LockScreenService
|
import io.element.android.features.lockscreen.api.LockScreenService
|
||||||
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
|
|
@ -26,61 +26,70 @@ import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@ContributesBinding(SessionScope::class)
|
@ContributesBinding(SessionScope::class)
|
||||||
@SingleIn(SessionScope::class)
|
@SingleIn(SessionScope::class)
|
||||||
@Inject
|
@Inject
|
||||||
class DefaultFtueService(
|
class DefaultFtueService(
|
||||||
private val sdkVersionProvider: BuildVersionSdkIntProvider,
|
private val sdkVersionProvider: BuildVersionSdkIntProvider,
|
||||||
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val permissionStateProvider: PermissionStateProvider,
|
private val permissionStateProvider: PermissionStateProvider,
|
||||||
private val lockScreenService: LockScreenService,
|
private val lockScreenService: LockScreenService,
|
||||||
private val sessionVerificationService: SessionVerificationService,
|
private val sessionVerificationService: SessionVerificationService,
|
||||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||||
) : FtueService {
|
) : FtueService {
|
||||||
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
|
private val userNeedsToConfirmSessionVerificationSuccess = MutableStateFlow(false)
|
||||||
|
|
||||||
/**
|
val ftueStepStateFlow = MutableStateFlow<InternalFtueState>(InternalFtueState.Unknown)
|
||||||
* This flow emits true when the FTUE flow is ready to be displayed.
|
|
||||||
* In this case, the FTUE flow is ready when the session verification status is known.
|
|
||||||
*/
|
|
||||||
val isVerificationStatusKnown = sessionVerificationService.sessionVerifiedStatus
|
|
||||||
.map { it != SessionVerifiedStatus.Unknown }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
|
|
||||||
override suspend fun reset() {
|
override val state = ftueStepStateFlow
|
||||||
analyticsService.reset()
|
.mapState {
|
||||||
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
when (it) {
|
||||||
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS)
|
is InternalFtueState.Unknown -> FtueState.Unknown
|
||||||
|
is InternalFtueState.Incomplete -> FtueState.Incomplete
|
||||||
|
is InternalFtueState.Complete -> FtueState.Complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
combine(
|
||||||
|
sessionVerificationService.sessionVerifiedStatus.onEach { sessionVerifiedStatus ->
|
||||||
|
if (sessionVerifiedStatus == SessionVerifiedStatus.NotVerified) {
|
||||||
|
// Ensure we wait for the user to confirm the session verified screen before going further
|
||||||
|
userNeedsToConfirmSessionVerificationSuccess.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userNeedsToConfirmSessionVerificationSuccess,
|
||||||
|
analyticsService.didAskUserConsentFlow.distinctUntilChanged(),
|
||||||
|
) {
|
||||||
|
updateFtueStep()
|
||||||
|
}
|
||||||
|
.launchIn(sessionCoroutineScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFtueStep() = sessionCoroutineScope.launch {
|
||||||
|
val step = getNextStep(null)
|
||||||
|
ftueStepStateFlow.value = when (step) {
|
||||||
|
null -> InternalFtueState.Complete
|
||||||
|
else -> InternalFtueState.Incomplete(step)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
private suspend fun getNextStep(completedStep: FtueStep? = null): FtueStep? =
|
||||||
sessionVerificationService.sessionVerifiedStatus
|
when (completedStep) {
|
||||||
.onEach { updateState() }
|
|
||||||
.launchIn(sessionCoroutineScope)
|
|
||||||
|
|
||||||
analyticsService.didAskUserConsentFlow
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.onEach { updateState() }
|
|
||||||
.launchIn(sessionCoroutineScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
|
||||||
when (currentStep) {
|
|
||||||
null -> if (!isSessionVerificationStateReady()) {
|
null -> if (!isSessionVerificationStateReady()) {
|
||||||
FtueStep.WaitingForInitialState
|
FtueStep.WaitingForInitialState
|
||||||
} else {
|
} else {
|
||||||
getNextStep(FtueStep.WaitingForInitialState)
|
getNextStep(FtueStep.WaitingForInitialState)
|
||||||
}
|
}
|
||||||
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
|
FtueStep.WaitingForInitialState -> if (isSessionNotVerified() || userNeedsToConfirmSessionVerificationSuccess.value) {
|
||||||
FtueStep.SessionVerification
|
FtueStep.SessionVerification
|
||||||
} else {
|
} else {
|
||||||
getNextStep(FtueStep.SessionVerification)
|
getNextStep(FtueStep.SessionVerification)
|
||||||
|
|
@ -108,9 +117,6 @@ class DefaultFtueService(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun isSessionNotVerified(): Boolean {
|
private suspend fun isSessionNotVerified(): Boolean {
|
||||||
// Wait until the session verification status is known
|
|
||||||
isVerificationStatusKnown.filter { it }.first()
|
|
||||||
|
|
||||||
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
|
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,14 +143,8 @@ class DefaultFtueService(
|
||||||
return lockScreenService.isSetupRequired().first()
|
return lockScreenService.isSetupRequired().first()
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
fun onUserCompletedSessionVerification() {
|
||||||
internal suspend fun updateState() {
|
userNeedsToConfirmSessionVerificationSuccess.value = false
|
||||||
val nextStep = getNextStep()
|
|
||||||
state.value = when {
|
|
||||||
// Final state, there aren't any more next steps
|
|
||||||
nextStep == null -> FtueState.Complete
|
|
||||||
else -> FtueState.Incomplete
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.api.state.FtueState
|
||||||
import io.element.android.features.ftue.impl.state.DefaultFtueService
|
import io.element.android.features.ftue.impl.state.DefaultFtueService
|
||||||
import io.element.android.features.ftue.impl.state.FtueStep
|
import io.element.android.features.ftue.impl.state.FtueStep
|
||||||
|
import io.element.android.features.ftue.impl.state.InternalFtueState
|
||||||
import io.element.android.features.lockscreen.api.LockScreenService
|
import io.element.android.features.lockscreen.api.LockScreenService
|
||||||
import io.element.android.features.lockscreen.test.FakeLockScreenService
|
import io.element.android.features.lockscreen.test.FakeLockScreenService
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
|
|
@ -26,8 +27,6 @@ import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analytics.noop.NoopAnalyticsService
|
import io.element.android.services.analytics.noop.NoopAnalyticsService
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
|
||||||
import io.element.android.tests.testutils.lambda.value
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -69,9 +68,11 @@ class DefaultFtueServiceTest {
|
||||||
analyticsService.setDidAskUserConsent()
|
analyticsService.setDidAskUserConsent()
|
||||||
permissionStateProvider.setPermissionGranted()
|
permissionStateProvider.setPermissionGranted()
|
||||||
lockScreenService.setIsPinSetup(true)
|
lockScreenService.setIsPinSetup(true)
|
||||||
service.updateState()
|
service.updateFtueStep()
|
||||||
|
service.state.test {
|
||||||
assertThat(service.state.value).isEqualTo(FtueState.Complete)
|
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
|
||||||
|
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -90,9 +91,11 @@ class DefaultFtueServiceTest {
|
||||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||||
permissionStateProvider.setPermissionGranted()
|
permissionStateProvider.setPermissionGranted()
|
||||||
lockScreenService.setIsPinSetup(true)
|
lockScreenService.setIsPinSetup(true)
|
||||||
service.updateState()
|
service.updateFtueStep()
|
||||||
|
service.state.test {
|
||||||
assertThat(service.state.value).isEqualTo(FtueState.Complete)
|
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
|
||||||
|
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -109,35 +112,30 @@ class DefaultFtueServiceTest {
|
||||||
permissionStateProvider = permissionStateProvider,
|
permissionStateProvider = permissionStateProvider,
|
||||||
lockScreenService = lockScreenService,
|
lockScreenService = lockScreenService,
|
||||||
)
|
)
|
||||||
val steps = mutableListOf<FtueStep?>()
|
|
||||||
|
|
||||||
// Session verification
|
service.ftueStepStateFlow.test {
|
||||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
|
||||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
// Session verification
|
||||||
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.SessionVerification))
|
||||||
// Notifications opt in
|
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
// User completes verification
|
||||||
permissionStateProvider.setPermissionGranted()
|
service.onUserCompletedSessionVerification()
|
||||||
|
// Notifications opt in
|
||||||
// Entering PIN code
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.NotificationsOptIn))
|
||||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
permissionStateProvider.setPermissionGranted()
|
||||||
lockScreenService.setIsPinSetup(true)
|
// Simulate event from NotificationsOptInNode.Callback.onNotificationsOptInFinished
|
||||||
|
service.updateFtueStep()
|
||||||
// Analytics opt in
|
// Entering PIN code
|
||||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.LockscreenSetup))
|
||||||
analyticsService.setDidAskUserConsent()
|
lockScreenService.setIsPinSetup(true)
|
||||||
|
// Simulate event from LockScreenEntryPoint.Callback.onSetupDone()
|
||||||
// Final step (null)
|
service.updateFtueStep()
|
||||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
// Analytics opt in
|
||||||
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
|
||||||
assertThat(steps).containsExactly(
|
analyticsService.setDidAskUserConsent()
|
||||||
FtueStep.SessionVerification,
|
// Final step
|
||||||
FtueStep.NotificationsOptIn,
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
|
||||||
FtueStep.LockscreenSetup,
|
}
|
||||||
FtueStep.AnalyticsOptIn,
|
|
||||||
// Final state
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -158,10 +156,13 @@ class DefaultFtueServiceTest {
|
||||||
permissionStateProvider.setPermissionGranted()
|
permissionStateProvider.setPermissionGranted()
|
||||||
lockScreenService.setIsPinSetup(true)
|
lockScreenService.setIsPinSetup(true)
|
||||||
|
|
||||||
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
service.ftueStepStateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
|
||||||
analyticsService.setDidAskUserConsent()
|
// Analytics opt in
|
||||||
assertThat(service.getNextStep(null)).isNull()
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
|
||||||
|
analyticsService.setDidAskUserConsent()
|
||||||
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -180,68 +181,30 @@ class DefaultFtueServiceTest {
|
||||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||||
lockScreenService.setIsPinSetup(true)
|
lockScreenService.setIsPinSetup(true)
|
||||||
|
|
||||||
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
service.ftueStepStateFlow.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
|
||||||
analyticsService.setDidAskUserConsent()
|
// Analytics opt in
|
||||||
assertThat(service.getNextStep(null)).isNull()
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
|
||||||
|
analyticsService.setDidAskUserConsent()
|
||||||
|
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `reset do the expected actions S`() = runTest {
|
|
||||||
val resetAnalyticsLambda = lambdaRecorder<Unit> { }
|
|
||||||
val analyticsService = FakeAnalyticsService(
|
|
||||||
resetLambda = resetAnalyticsLambda
|
|
||||||
)
|
|
||||||
val resetPermissionLambda = lambdaRecorder<String, Unit> { }
|
|
||||||
val permissionStateProvider = FakePermissionStateProvider(
|
|
||||||
resetPermissionLambda = resetPermissionLambda
|
|
||||||
)
|
|
||||||
val service = createDefaultFtueService(
|
|
||||||
sdkIntVersion = Build.VERSION_CODES.S,
|
|
||||||
permissionStateProvider = permissionStateProvider,
|
|
||||||
analyticsService = analyticsService,
|
|
||||||
)
|
|
||||||
service.reset()
|
|
||||||
resetAnalyticsLambda.assertions().isCalledOnce()
|
|
||||||
resetPermissionLambda.assertions().isNeverCalled()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `reset do the expected actions TIRAMISU`() = runTest {
|
|
||||||
val resetLambda = lambdaRecorder<Unit> { }
|
|
||||||
val analyticsService = FakeAnalyticsService(
|
|
||||||
resetLambda = resetLambda
|
|
||||||
)
|
|
||||||
val resetPermissionLambda = lambdaRecorder<String, Unit> { }
|
|
||||||
val permissionStateProvider = FakePermissionStateProvider(
|
|
||||||
resetPermissionLambda = resetPermissionLambda
|
|
||||||
)
|
|
||||||
val service = createDefaultFtueService(
|
|
||||||
sdkIntVersion = Build.VERSION_CODES.TIRAMISU,
|
|
||||||
permissionStateProvider = permissionStateProvider,
|
|
||||||
analyticsService = analyticsService,
|
|
||||||
)
|
|
||||||
service.reset()
|
|
||||||
resetLambda.assertions().isCalledOnce()
|
|
||||||
resetPermissionLambda.assertions().isCalledOnce()
|
|
||||||
.with(value("android.permission.POST_NOTIFICATIONS"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun TestScope.createDefaultFtueService(
|
|
||||||
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
|
|
||||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
|
||||||
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
|
|
||||||
lockScreenService: LockScreenService = FakeLockScreenService(),
|
|
||||||
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
|
||||||
// First version where notification permission is required
|
|
||||||
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
|
|
||||||
) = DefaultFtueService(
|
|
||||||
sessionCoroutineScope = backgroundScope,
|
|
||||||
sessionVerificationService = sessionVerificationService,
|
|
||||||
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
|
|
||||||
analyticsService = analyticsService,
|
|
||||||
permissionStateProvider = permissionStateProvider,
|
|
||||||
lockScreenService = lockScreenService,
|
|
||||||
sessionPreferencesStore = sessionPreferencesStore,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun TestScope.createDefaultFtueService(
|
||||||
|
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
|
||||||
|
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||||
|
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
|
||||||
|
lockScreenService: LockScreenService = FakeLockScreenService(),
|
||||||
|
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||||
|
// First version where notification permission is required
|
||||||
|
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
|
||||||
|
) = DefaultFtueService(
|
||||||
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
sessionVerificationService = sessionVerificationService,
|
||||||
|
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
|
||||||
|
analyticsService = analyticsService,
|
||||||
|
permissionStateProvider = permissionStateProvider,
|
||||||
|
lockScreenService = lockScreenService,
|
||||||
|
sessionPreferencesStore = sessionPreferencesStore,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,11 @@ package io.element.android.features.ftue.test
|
||||||
|
|
||||||
import io.element.android.features.ftue.api.state.FtueService
|
import io.element.android.features.ftue.api.state.FtueService
|
||||||
import io.element.android.features.ftue.api.state.FtueState
|
import io.element.android.features.ftue.api.state.FtueState
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
class FakeFtueService(
|
class FakeFtueService : FtueService {
|
||||||
private val resetLambda: () -> Unit = { lambdaError() },
|
|
||||||
) : FtueService {
|
|
||||||
override val state: MutableStateFlow<FtueState> = MutableStateFlow(FtueState.Unknown)
|
override val state: MutableStateFlow<FtueState> = MutableStateFlow(FtueState.Unknown)
|
||||||
|
|
||||||
override suspend fun reset() {
|
|
||||||
resetLambda()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun emitState(newState: FtueState) {
|
suspend fun emitState(newState: FtueState) {
|
||||||
state.emit(newState)
|
state.emit(newState)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,5 @@ interface HomeEntryPoint : FeatureEntryPoint {
|
||||||
fun onSessionConfirmRecoveryKeyClick()
|
fun onSessionConfirmRecoveryKeyClick()
|
||||||
fun onRoomSettingsClick(roomId: RoomId)
|
fun onRoomSettingsClick(roomId: RoomId)
|
||||||
fun onReportBugClick()
|
fun onReportBugClick()
|
||||||
fun onLogoutForNativeSlidingSyncMigrationNeeded()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2022-2024 New Vector Ltd.
|
* Copyright 2022-2024 New Vector Ltd.
|
||||||
|
|
@ -55,16 +56,10 @@ dependencies {
|
||||||
implementation(libs.haze.materials)
|
implementation(libs.haze.materials)
|
||||||
implementation(projects.features.reportroom.api)
|
implementation(projects.features.reportroom.api)
|
||||||
implementation(projects.features.changeroommemberroles.api)
|
implementation(projects.features.changeroommemberroles.api)
|
||||||
|
implementation(projects.libraries.previewutils)
|
||||||
api(projects.features.home.api)
|
api(projects.features.home.api)
|
||||||
|
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
testCommonDependencies(libs, true)
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
testImplementation(libs.test.junit)
|
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(projects.features.invite.test)
|
testImplementation(projects.features.invite.test)
|
||||||
testImplementation(projects.features.logout.test)
|
testImplementation(projects.features.logout.test)
|
||||||
testImplementation(projects.features.networkmonitor.test)
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
|
|
@ -79,5 +74,4 @@ dependencies {
|
||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.services.toolbox.test)
|
testImplementation(projects.services.toolbox.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ class HomeFlowNode(
|
||||||
stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false))
|
stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun rootNode(buildContext: BuildContext): Node {
|
private fun rootNode(buildContext: BuildContext): Node {
|
||||||
return node(buildContext) { modifier ->
|
return node(buildContext) { modifier ->
|
||||||
val state by stateFlow.collectAsState()
|
val state by stateFlow.collectAsState()
|
||||||
val activity = requireNotNull(LocalActivity.current)
|
val activity = requireNotNull(LocalActivity.current)
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ private fun HomeScaffold(
|
||||||
.hazeSource(state = hazeState),
|
.hazeSource(state = hazeState),
|
||||||
state = state.homeSpacesState,
|
state = state.homeSpacesState,
|
||||||
onSpaceClick = { spaceId ->
|
onSpaceClick = { spaceId ->
|
||||||
// TODO
|
onRoomClick(spaceId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -102,13 +103,44 @@ class RoomListDataSource(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) {
|
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) {
|
||||||
|
// Used to detect duplicates in the room list summaries - see comment below
|
||||||
|
data class CacheResult(val index: Int, val fromCache: Boolean)
|
||||||
|
val cachingResults = mutableMapOf<RoomId, MutableList<CacheResult>>()
|
||||||
|
|
||||||
val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
|
val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
diffCache.get(index) ?: buildAndCacheItem(roomSummaries, index)
|
diffCache.get(index)?.let { cachedItem ->
|
||||||
|
// Add the cached item to the caching results
|
||||||
|
val pairs = cachingResults.getOrDefault(cachedItem.roomId, mutableListOf())
|
||||||
|
pairs.add(CacheResult(index, fromCache = true))
|
||||||
|
cachingResults[cachedItem.roomId] = pairs
|
||||||
|
cachedItem
|
||||||
|
} ?: run {
|
||||||
|
roomSummaries.getOrNull(index)?.roomId?.let {
|
||||||
|
// Add the non-cached item to the caching results
|
||||||
|
val pairs = cachingResults.getOrDefault(it, mutableListOf())
|
||||||
|
pairs.add(CacheResult(index, fromCache = false))
|
||||||
|
cachingResults[it] = pairs
|
||||||
|
}
|
||||||
|
buildAndCacheItem(roomSummaries, index)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
roomSummaries.getOrNull(index)?.roomId?.let {
|
||||||
|
// Add the non-cached item to the caching results
|
||||||
|
val pairs = cachingResults.getOrDefault(it, mutableListOf())
|
||||||
|
pairs.add(CacheResult(index, fromCache = false))
|
||||||
|
cachingResults[it] = pairs
|
||||||
|
}
|
||||||
buildAndCacheItem(roomSummaries, index)
|
buildAndCacheItem(roomSummaries, index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed
|
||||||
|
val duplicates = cachingResults.filter { (_, operations) -> operations.size > 1 }
|
||||||
|
if (duplicates.isNotEmpty()) {
|
||||||
|
Timber.e("Found duplicates in room summaries after an UI update: $duplicates. This could be a race condition/caching issue of some kind")
|
||||||
|
}
|
||||||
|
|
||||||
_allRooms.emit(roomListRoomSummaries.toImmutableList())
|
_allRooms.emit(roomListRoomSummaries.toImmutableList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.mapState
|
|
||||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -44,6 +43,7 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomList
|
import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||||
|
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
|
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
|
||||||
|
|
@ -101,12 +101,7 @@ class RoomListPresenter(
|
||||||
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
|
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Avatar indicator
|
// Avatar indicator
|
||||||
val hideInvitesAvatar by remember {
|
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
|
||||||
client
|
|
||||||
.mediaPreviewService()
|
|
||||||
.mediaPreviewConfigFlow
|
|
||||||
.mapState { config -> config.hideInviteAvatar }
|
|
||||||
}.collectAsState()
|
|
||||||
|
|
||||||
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
||||||
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
|
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,9 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.features.invite.api.SeenInvitesStore
|
import io.element.android.features.invite.api.SeenInvitesStore
|
||||||
import io.element.android.features.invite.api.seenSpaceIds
|
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.mapState
|
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
import kotlinx.collections.immutable.toPersistentSet
|
import kotlinx.collections.immutable.toPersistentSet
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
@ -28,15 +27,10 @@ class HomeSpacesPresenter(
|
||||||
) : Presenter<HomeSpacesState> {
|
) : Presenter<HomeSpacesState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): HomeSpacesState {
|
override fun present(): HomeSpacesState {
|
||||||
val hideInvitesAvatar by remember {
|
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
|
||||||
client
|
val spaceRooms by client.spaceService.spaceRoomsFlow.collectAsState(emptyList())
|
||||||
.mediaPreviewService()
|
|
||||||
.mediaPreviewConfigFlow
|
|
||||||
.mapState { config -> config.hideInviteAvatar }
|
|
||||||
}.collectAsState()
|
|
||||||
val spaceRooms by client.spaceService.spaceRooms.collectAsState(emptyList())
|
|
||||||
val seenSpaceInvites by remember {
|
val seenSpaceInvites by remember {
|
||||||
seenInvitesStore.seenSpaceIds().map { it.toPersistentSet() }
|
seenInvitesStore.seenRoomIds().map { it.toPersistentSet() }
|
||||||
}.collectAsState(persistentSetOf())
|
}.collectAsState(persistentSetOf())
|
||||||
|
|
||||||
fun handleEvents(event: HomeSpacesEvents) {
|
fun handleEvents(event: HomeSpacesEvents) {
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@
|
||||||
|
|
||||||
package io.element.android.features.home.impl.spaces
|
package io.element.android.features.home.impl.spaces
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.SpaceId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
|
|
||||||
data class HomeSpacesState(
|
data class HomeSpacesState(
|
||||||
val space: CurrentSpace,
|
val space: CurrentSpace,
|
||||||
val spaceRooms: List<SpaceRoom>,
|
val spaceRooms: List<SpaceRoom>,
|
||||||
val seenSpaceInvites: ImmutableSet<SpaceId>,
|
val seenSpaceInvites: ImmutableSet<RoomId>,
|
||||||
val hideInvitesAvatar: Boolean,
|
val hideInvitesAvatar: Boolean,
|
||||||
val eventSink: (HomeSpacesEvents) -> Unit,
|
val eventSink: (HomeSpacesEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@
|
||||||
package io.element.android.features.home.impl.spaces
|
package io.element.android.features.home.impl.spaces
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.matrix.api.core.SpaceId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
||||||
|
import io.element.android.libraries.previewutils.room.aSpaceRoom
|
||||||
import kotlinx.collections.immutable.toImmutableSet
|
import kotlinx.collections.immutable.toImmutableSet
|
||||||
|
|
||||||
open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
|
open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
|
||||||
|
|
@ -18,12 +19,12 @@ open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
|
||||||
aHomeSpacesState(
|
aHomeSpacesState(
|
||||||
spaceRooms = SpaceRoomProvider().values.toList(),
|
spaceRooms = SpaceRoomProvider().values.toList(),
|
||||||
seenSpaceInvites = setOf(
|
seenSpaceInvites = setOf(
|
||||||
SpaceId("!spaceId3:example.com"),
|
RoomId("!spaceId3:example.com"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
aHomeSpacesState(
|
aHomeSpacesState(
|
||||||
space = CurrentSpace.Space(
|
space = CurrentSpace.Space(
|
||||||
spaceRoom = aSpaceRooms(spaceId = SpaceId("!mySpace:example.com"))
|
spaceRoom = aSpaceRoom(roomId = RoomId("!mySpace:example.com"))
|
||||||
),
|
),
|
||||||
spaceRooms = aListOfSpaceRooms(),
|
spaceRooms = aListOfSpaceRooms(),
|
||||||
),
|
),
|
||||||
|
|
@ -33,7 +34,7 @@ open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
|
||||||
internal fun aHomeSpacesState(
|
internal fun aHomeSpacesState(
|
||||||
space: CurrentSpace = CurrentSpace.Root,
|
space: CurrentSpace = CurrentSpace.Root,
|
||||||
spaceRooms: List<SpaceRoom> = aListOfSpaceRooms(),
|
spaceRooms: List<SpaceRoom> = aListOfSpaceRooms(),
|
||||||
seenSpaceInvites: Set<SpaceId> = emptySet(),
|
seenSpaceInvites: Set<RoomId> = emptySet(),
|
||||||
hideInvitesAvatar: Boolean = false,
|
hideInvitesAvatar: Boolean = false,
|
||||||
eventSink: (HomeSpacesEvents) -> Unit = {},
|
eventSink: (HomeSpacesEvents) -> Unit = {},
|
||||||
) = HomeSpacesState(
|
) = HomeSpacesState(
|
||||||
|
|
@ -46,8 +47,8 @@ internal fun aHomeSpacesState(
|
||||||
|
|
||||||
fun aListOfSpaceRooms(): List<SpaceRoom> {
|
fun aListOfSpaceRooms(): List<SpaceRoom> {
|
||||||
return listOf(
|
return listOf(
|
||||||
aSpaceRooms(spaceId = SpaceId("!spaceId0:example.com")),
|
aSpaceRoom(roomId = RoomId("!spaceId0:example.com")),
|
||||||
aSpaceRooms(spaceId = SpaceId("!spaceId1:example.com")),
|
aSpaceRoom(roomId = RoomId("!spaceId1:example.com")),
|
||||||
aSpaceRooms(spaceId = SpaceId("!spaceId2:example.com")),
|
aSpaceRoom(roomId = RoomId("!spaceId2:example.com")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,18 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.matrix.api.core.SpaceId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView
|
import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView
|
||||||
import io.element.android.libraries.matrix.ui.components.SpaceHeaderView
|
import io.element.android.libraries.matrix.ui.components.SpaceHeaderView
|
||||||
|
import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView
|
||||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeSpacesView(
|
fun HomeSpacesView(
|
||||||
state: HomeSpacesState,
|
state: HomeSpacesState,
|
||||||
onSpaceClick: (SpaceId) -> Unit,
|
onSpaceClick: (RoomId) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier) {
|
LazyColumn(modifier) {
|
||||||
|
|
@ -51,15 +52,18 @@ fun HomeSpacesView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.spaceRooms.forEach {
|
state.spaceRooms.forEach { spaceRoom ->
|
||||||
item(it.spaceId) {
|
item(spaceRoom.roomId) {
|
||||||
val isInvitation = it.state == CurrentUserMembership.INVITED
|
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
|
||||||
HomeSpaceItemView(
|
SpaceRoomItemView(
|
||||||
spaceRoom = it,
|
spaceRoom = spaceRoom,
|
||||||
showUnreadIndicator = isInvitation && it.spaceId !in state.seenSpaceInvites,
|
showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
|
||||||
hideAvatars = isInvitation && state.hideInvitesAvatar,
|
hideAvatars = isInvitation && state.hideInvitesAvatar,
|
||||||
onClick = {
|
onClick = {
|
||||||
onSpaceClick(it.spaceId)
|
onSpaceClick(spaceRoom.roomId)
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
// TODO
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,77 +8,44 @@
|
||||||
package io.element.android.features.home.impl.spaces
|
package io.element.android.features.home.impl.spaces
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SpaceId
|
|
||||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.api.room.RoomType
|
|
||||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
|
||||||
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.previewutils.room.aSpaceRoom
|
||||||
|
|
||||||
class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
|
class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
|
||||||
override val values: Sequence<SpaceRoom> = sequenceOf(
|
override val values: Sequence<SpaceRoom> = sequenceOf(
|
||||||
aSpaceRooms(),
|
aSpaceRoom(),
|
||||||
aSpaceRooms(
|
aSpaceRoom(
|
||||||
numJoinedMembers = 5,
|
numJoinedMembers = 5,
|
||||||
childrenCount = 10,
|
childrenCount = 10,
|
||||||
worldReadable = true,
|
worldReadable = true,
|
||||||
spaceId = SpaceId("!spaceId0:example.com"),
|
roomId = RoomId("!spaceId0:example.com"),
|
||||||
),
|
),
|
||||||
aSpaceRooms(
|
aSpaceRoom(
|
||||||
numJoinedMembers = 5,
|
numJoinedMembers = 5,
|
||||||
childrenCount = 10,
|
childrenCount = 10,
|
||||||
worldReadable = true,
|
worldReadable = true,
|
||||||
avatarUrl = "anUrl",
|
avatarUrl = "anUrl",
|
||||||
spaceId = SpaceId("!spaceId1:example.com"),
|
roomId = RoomId("!spaceId1:example.com"),
|
||||||
),
|
),
|
||||||
aSpaceRooms(
|
aSpaceRoom(
|
||||||
name = null,
|
name = null,
|
||||||
numJoinedMembers = 5,
|
numJoinedMembers = 5,
|
||||||
childrenCount = 10,
|
childrenCount = 10,
|
||||||
worldReadable = true,
|
worldReadable = true,
|
||||||
avatarUrl = "anUrl",
|
avatarUrl = "anUrl",
|
||||||
spaceId = SpaceId("!spaceId2:example.com"),
|
roomId = RoomId("!spaceId2:example.com"),
|
||||||
state = CurrentUserMembership.INVITED,
|
state = CurrentUserMembership.INVITED,
|
||||||
),
|
),
|
||||||
aSpaceRooms(
|
aSpaceRoom(
|
||||||
name = null,
|
name = null,
|
||||||
numJoinedMembers = 5,
|
numJoinedMembers = 5,
|
||||||
childrenCount = 10,
|
childrenCount = 10,
|
||||||
worldReadable = true,
|
worldReadable = true,
|
||||||
avatarUrl = "anUrl",
|
avatarUrl = "anUrl",
|
||||||
spaceId = SpaceId("!spaceId3:example.com"),
|
roomId = RoomId("!spaceId3:example.com"),
|
||||||
state = CurrentUserMembership.INVITED,
|
state = CurrentUserMembership.INVITED,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aSpaceRooms(
|
|
||||||
name: String? = "Space name",
|
|
||||||
avatarUrl: String? = null,
|
|
||||||
canonicalAlias: RoomAlias? = null,
|
|
||||||
childrenCount: Int = 0,
|
|
||||||
guestCanJoin: Boolean = false,
|
|
||||||
heroes: List<MatrixUser> = emptyList(),
|
|
||||||
joinRule: JoinRule? = null,
|
|
||||||
numJoinedMembers: Int = 0,
|
|
||||||
spaceId: SpaceId = SpaceId("!spaceId:example.com"),
|
|
||||||
roomType: RoomType = RoomType.Space,
|
|
||||||
state: CurrentUserMembership? = null,
|
|
||||||
topic: String? = null,
|
|
||||||
worldReadable: Boolean = false,
|
|
||||||
) = SpaceRoom(
|
|
||||||
name = name,
|
|
||||||
avatarUrl = avatarUrl,
|
|
||||||
canonicalAlias = canonicalAlias,
|
|
||||||
childrenCount = childrenCount,
|
|
||||||
guestCanJoin = guestCanJoin,
|
|
||||||
heroes = heroes,
|
|
||||||
joinRule = joinRule,
|
|
||||||
numJoinedMembers = numJoinedMembers,
|
|
||||||
spaceId = spaceId,
|
|
||||||
roomType = roomType,
|
|
||||||
state = state,
|
|
||||||
topic = topic,
|
|
||||||
worldReadable = worldReadable
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<string name="full_screen_intent_banner_message">"Er mwyn sicrhau fyddwch chi ddim yn colli galwad bwysig, newidiwch eich gosodiadau i ganiatáu hysbysiadau sgrin lawn pan fydd eich ffôn wedi\'i gloi."</string>
|
<string name="full_screen_intent_banner_message">"Er mwyn sicrhau fyddwch chi ddim yn colli galwad bwysig, newidiwch eich gosodiadau i ganiatáu hysbysiadau sgrin lawn pan fydd eich ffôn wedi\'i gloi."</string>
|
||||||
<string name="full_screen_intent_banner_title">"Gwella profiad eich galwadau"</string>
|
<string name="full_screen_intent_banner_title">"Gwella profiad eich galwadau"</string>
|
||||||
<string name="screen_home_tab_chats">"Sgyrsiau"</string>
|
<string name="screen_home_tab_chats">"Sgyrsiau"</string>
|
||||||
|
<string name="screen_home_tab_spaces">"Gofodau"</string>
|
||||||
<string name="screen_invites_decline_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?"</string>
|
<string name="screen_invites_decline_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?"</string>
|
||||||
<string name="screen_invites_decline_chat_title">"Gwrthod y gwahoddiad"</string>
|
<string name="screen_invites_decline_chat_title">"Gwrthod y gwahoddiad"</string>
|
||||||
<string name="screen_invites_decline_direct_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?"</string>
|
<string name="screen_invites_decline_direct_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?"</string>
|
||||||
|
|
@ -32,6 +33,7 @@ Am y tro, gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"</strin
|
||||||
<string name="screen_roomlist_filter_invites">"Gwahoddiadau"</string>
|
<string name="screen_roomlist_filter_invites">"Gwahoddiadau"</string>
|
||||||
<string name="screen_roomlist_filter_invites_empty_state_title">"Does gennych chi ddim gwahoddiadau yn aros."</string>
|
<string name="screen_roomlist_filter_invites_empty_state_title">"Does gennych chi ddim gwahoddiadau yn aros."</string>
|
||||||
<string name="screen_roomlist_filter_low_priority">"Blaenoriaeth Isel"</string>
|
<string name="screen_roomlist_filter_low_priority">"Blaenoriaeth Isel"</string>
|
||||||
|
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Does gennych chi ddim sgyrsiau blaenoriaeth isel eto"</string>
|
||||||
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"</string>
|
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"</string>
|
||||||
<string name="screen_roomlist_filter_mixed_empty_state_title">"Does gennych chi ddim sgyrsiau ar gyfer y dewis hwn"</string>
|
<string name="screen_roomlist_filter_mixed_empty_state_title">"Does gennych chi ddim sgyrsiau ar gyfer y dewis hwn"</string>
|
||||||
<string name="screen_roomlist_filter_people">"Pobl"</string>
|
<string name="screen_roomlist_filter_people">"Pobl"</string>
|
||||||
|
|
|
||||||
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">"Kutsut"</string>
|
||||||
<string name="screen_roomlist_filter_invites_empty_state_title">"Sinulla ei ole yhtään odottavaa kutsua."</string>
|
<string name="screen_roomlist_filter_invites_empty_state_title">"Sinulla ei ole yhtään odottavaa kutsua."</string>
|
||||||
<string name="screen_roomlist_filter_low_priority">"Matala prioriteetti"</string>
|
<string name="screen_roomlist_filter_low_priority">"Matala prioriteetti"</string>
|
||||||
|
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Sinulla ei ole vielä yhtään matalan prioriteetin keskustelua"</string>
|
||||||
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi."</string>
|
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi."</string>
|
||||||
<string name="screen_roomlist_filter_mixed_empty_state_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string>
|
<string name="screen_roomlist_filter_mixed_empty_state_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string>
|
||||||
<string name="screen_roomlist_filter_people">"Ihmiset"</string>
|
<string name="screen_roomlist_filter_people">"Ihmiset"</string>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<string name="full_screen_intent_banner_message">"For å sikre at du aldri går glipp av en viktig samtale, må du endre innstillingene dine for å tillate fullskjermvarsler når telefonen er låst."</string>
|
<string name="full_screen_intent_banner_message">"For å sikre at du aldri går glipp av en viktig samtale, må du endre innstillingene dine for å tillate fullskjermvarsler når telefonen er låst."</string>
|
||||||
<string name="full_screen_intent_banner_title">"Forbedre samtaleopplevelsen din"</string>
|
<string name="full_screen_intent_banner_title">"Forbedre samtaleopplevelsen din"</string>
|
||||||
<string name="screen_home_tab_chats">"Chatter"</string>
|
<string name="screen_home_tab_chats">"Chatter"</string>
|
||||||
|
<string name="screen_home_tab_spaces">"Områder"</string>
|
||||||
<string name="screen_invites_decline_chat_message">"Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?"</string>
|
<string name="screen_invites_decline_chat_message">"Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?"</string>
|
||||||
<string name="screen_invites_decline_chat_title">"Avvis invitasjon"</string>
|
<string name="screen_invites_decline_chat_title">"Avvis invitasjon"</string>
|
||||||
<string name="screen_invites_decline_direct_chat_message">"Er du sikker på at du vil avslå denne private chatten med %1$s?"</string>
|
<string name="screen_invites_decline_direct_chat_message">"Er du sikker på at du vil avslå denne private chatten med %1$s?"</string>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
<string name="screen_home_tab_spaces">"Espaços"</string>
|
<string name="screen_home_tab_spaces">"Espaços"</string>
|
||||||
<string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string>
|
<string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string>
|
||||||
<string name="screen_invites_decline_chat_title">"Rejeitar convite"</string>
|
<string name="screen_invites_decline_chat_title">"Rejeitar convite"</string>
|
||||||
<string name="screen_invites_decline_direct_chat_message">"Tem a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
|
<string name="screen_invites_decline_direct_chat_message">"Tens a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
|
||||||
<string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string>
|
<string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string>
|
||||||
<string name="screen_invites_empty_list">"Sem convites"</string>
|
<string name="screen_invites_empty_list">"Sem convites"</string>
|
||||||
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>
|
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>
|
||||||
|
|
@ -33,6 +33,7 @@ Por enquanto, podes anular a seleção dos filtros para veres as tuas outras con
|
||||||
<string name="screen_roomlist_filter_invites">"Convites"</string>
|
<string name="screen_roomlist_filter_invites">"Convites"</string>
|
||||||
<string name="screen_roomlist_filter_invites_empty_state_title">"Não tens nenhum convite pendente."</string>
|
<string name="screen_roomlist_filter_invites_empty_state_title">"Não tens nenhum convite pendente."</string>
|
||||||
<string name="screen_roomlist_filter_low_priority">"Prioridade baixa"</string>
|
<string name="screen_roomlist_filter_low_priority">"Prioridade baixa"</string>
|
||||||
|
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Ainda não tens conversas de prioridade baixa"</string>
|
||||||
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Podes anular a seleção dos filtros para veres as tuas outras conversas"</string>
|
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Podes anular a seleção dos filtros para veres as tuas outras conversas"</string>
|
||||||
<string name="screen_roomlist_filter_mixed_empty_state_title">"Não tens nenhuma conversa selecionada"</string>
|
<string name="screen_roomlist_filter_mixed_empty_state_title">"Não tens nenhuma conversa selecionada"</string>
|
||||||
<string name="screen_roomlist_filter_people">"Pessoas"</string>
|
<string name="screen_roomlist_filter_people">"Pessoas"</string>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
<string name="screen_roomlist_filter_invites">"邀請"</string>
|
<string name="screen_roomlist_filter_invites">"邀請"</string>
|
||||||
<string name="screen_roomlist_filter_invites_empty_state_title">"您沒有任何擱置中的邀請。"</string>
|
<string name="screen_roomlist_filter_invites_empty_state_title">"您沒有任何擱置中的邀請。"</string>
|
||||||
<string name="screen_roomlist_filter_low_priority">"低優先度"</string>
|
<string name="screen_roomlist_filter_low_priority">"低優先度"</string>
|
||||||
|
<string name="screen_roomlist_filter_low_priority_empty_state_title">"您尚無任何低優先程度聊天"</string>
|
||||||
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"您可以取消選取篩選條件以檢視其他聊天"</string>
|
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"您可以取消選取篩選條件以檢視其他聊天"</string>
|
||||||
<string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string>
|
<string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string>
|
||||||
<string name="screen_roomlist_filter_people">"夥伴"</string>
|
<string name="screen_roomlist_filter_people">"夥伴"</string>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
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.RoomInfo
|
||||||
import io.element.android.libraries.matrix.api.room.isDm
|
import io.element.android.libraries.matrix.api.room.isDm
|
||||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
||||||
|
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
|
|
@ -36,3 +37,11 @@ fun RoomInfo.toInviteData(): InviteData {
|
||||||
isDm = isDm,
|
isDm = isDm,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun SpaceRoom.toInviteData(): InviteData {
|
||||||
|
return InviteData(
|
||||||
|
roomId = roomId,
|
||||||
|
roomName = name ?: roomId.value,
|
||||||
|
isDm = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,7 @@
|
||||||
package io.element.android.features.invite.api
|
package io.element.android.features.invite.api
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SpaceId
|
|
||||||
import io.element.android.libraries.matrix.api.core.toSpaceId
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
|
|
||||||
interface SeenInvitesStore {
|
interface SeenInvitesStore {
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,9 +35,3 @@ interface SeenInvitesStore {
|
||||||
*/
|
*/
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SeenInvitesStore.seenSpaceIds(): Flow<Set<SpaceId>> {
|
|
||||||
return seenRoomIds().map { roomIds ->
|
|
||||||
roomIds.map { it.toSpaceId() }.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
||||||
interface AcceptDeclineInviteView {
|
fun interface AcceptDeclineInviteView {
|
||||||
@Composable
|
@Composable
|
||||||
fun Render(
|
fun Render(
|
||||||
state: AcceptDeclineInviteState,
|
state: AcceptDeclineInviteState,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,6 @@ import com.bumble.appyx.core.node.Node
|
||||||
import io.element.android.features.invite.api.InviteData
|
import io.element.android.features.invite.api.InviteData
|
||||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
|
||||||
interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
|
fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
|
||||||
fun createNode(parentNode: Node, buildContext: BuildContext, inviteData: InviteData): Node
|
fun createNode(parentNode: Node, buildContext: BuildContext, inviteData: InviteData): Node
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
* Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
|
@ -36,17 +37,9 @@ dependencies {
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
implementation(projects.libraries.push.api)
|
implementation(projects.libraries.push.api)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(projects.features.invite.test)
|
testImplementation(projects.features.invite.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class DeclineAndBlockPresenter(
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : Presenter<DeclineAndBlockState> {
|
) : Presenter<DeclineAndBlockState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
fun interface Factory {
|
||||||
fun create(inviteData: InviteData): DeclineAndBlockPresenter
|
fun create(inviteData: InviteData): DeclineAndBlockPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<string name="screen_decline_and_block_title">"Rejeitar e bloquear"</string>
|
<string name="screen_decline_and_block_title">"Rejeitar e bloquear"</string>
|
||||||
<string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string>
|
<string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string>
|
||||||
<string name="screen_invites_decline_chat_title">"Rejeitar convite"</string>
|
<string name="screen_invites_decline_chat_title">"Rejeitar convite"</string>
|
||||||
<string name="screen_invites_decline_direct_chat_message">"Tem a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
|
<string name="screen_invites_decline_direct_chat_message">"Tens a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
|
||||||
<string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string>
|
<string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string>
|
||||||
<string name="screen_invites_empty_list">"Sem convites"</string>
|
<string name="screen_invites_empty_list">"Sem convites"</string>
|
||||||
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>
|
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.invite.api.InviteData
|
import io.element.android.features.invite.api.InviteData
|
||||||
import io.element.android.features.invite.impl.DeclineInvite
|
import io.element.android.features.invite.impl.DeclineInvite
|
||||||
import io.element.android.features.invite.impl.fake.FakeDeclineInvite
|
import io.element.android.features.invite.impl.fake.FakeDeclineInvite
|
||||||
|
import io.element.android.features.invite.test.anInviteData
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.assert
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
|
@ -148,28 +148,16 @@ class DeclineAndBlockPresenterTest {
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(value(A_ROOM_ID), value(true), value(false), value(""))
|
.with(value(A_ROOM_ID), value(true), value(false), value(""))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private fun anInviteData(
|
|
||||||
roomId: RoomId = A_ROOM_ID,
|
internal fun createDeclineAndBlockPresenter(
|
||||||
name: String = A_ROOM_NAME,
|
inviteData: InviteData = anInviteData(),
|
||||||
isDm: Boolean = false,
|
declineInvite: DeclineInvite = FakeDeclineInvite(),
|
||||||
): InviteData {
|
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
||||||
return InviteData(
|
): DeclineAndBlockPresenter {
|
||||||
roomId = roomId,
|
return DeclineAndBlockPresenter(
|
||||||
roomName = name,
|
inviteData = inviteData,
|
||||||
isDm = isDm,
|
declineInvite = declineInvite,
|
||||||
)
|
snackbarDispatcher = snackbarDispatcher,
|
||||||
}
|
)
|
||||||
|
|
||||||
private fun createDeclineAndBlockPresenter(
|
|
||||||
inviteData: InviteData = anInviteData(),
|
|
||||||
declineInvite: DeclineInvite = FakeDeclineInvite(),
|
|
||||||
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
|
||||||
): DeclineAndBlockPresenter {
|
|
||||||
return DeclineAndBlockPresenter(
|
|
||||||
inviteData = inviteData,
|
|
||||||
declineInvite = declineInvite,
|
|
||||||
snackbarDispatcher = snackbarDispatcher,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
package io.element.android.features.invitepeople.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
|
||||||
interface InvitePeopleState {
|
interface InvitePeopleState {
|
||||||
val canInvite: Boolean
|
val canInvite: Boolean
|
||||||
val isSearchActive: Boolean
|
val isSearchActive: Boolean
|
||||||
|
val sendInvitesAction: AsyncAction<Unit>
|
||||||
val eventSink: (InvitePeopleEvents) -> Unit
|
val eventSink: (InvitePeopleEvents) -> Unit
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,28 +8,33 @@
|
||||||
package io.element.android.features.invitepeople.api
|
package io.element.android.features.invitepeople.api
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
|
||||||
class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> {
|
class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> {
|
||||||
override val values: Sequence<InvitePeopleState>
|
override val values: Sequence<InvitePeopleState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aPreviewInvitePeopleState(),
|
aPreviewInvitePeopleState(),
|
||||||
aPreviewInvitePeopleState(canInvite = true),
|
aPreviewInvitePeopleState(canInvite = true),
|
||||||
aPreviewInvitePeopleState(isSearchActive = true)
|
aPreviewInvitePeopleState(isSearchActive = true),
|
||||||
|
aPreviewInvitePeopleState(sendInvitesAction = AsyncAction.Loading),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class PreviewInvitePeopleState(
|
private data class PreviewInvitePeopleState(
|
||||||
override val canInvite: Boolean,
|
override val canInvite: Boolean,
|
||||||
override val isSearchActive: Boolean,
|
override val isSearchActive: Boolean,
|
||||||
|
override val sendInvitesAction: AsyncAction<Unit>,
|
||||||
override val eventSink: (InvitePeopleEvents) -> Unit,
|
override val eventSink: (InvitePeopleEvents) -> Unit,
|
||||||
) : InvitePeopleState
|
) : InvitePeopleState
|
||||||
|
|
||||||
private fun aPreviewInvitePeopleState(
|
private fun aPreviewInvitePeopleState(
|
||||||
canInvite: Boolean = false,
|
canInvite: Boolean = false,
|
||||||
isSearchActive: Boolean = false,
|
isSearchActive: Boolean = false,
|
||||||
|
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
eventSink: (InvitePeopleEvents) -> Unit = {},
|
eventSink: (InvitePeopleEvents) -> Unit = {},
|
||||||
) = PreviewInvitePeopleState(
|
) = PreviewInvitePeopleState(
|
||||||
canInvite = canInvite,
|
canInvite = canInvite,
|
||||||
isSearchActive = isSearchActive,
|
isSearchActive = isSearchActive,
|
||||||
|
sendInvitesAction = sendInvitesAction,
|
||||||
eventSink = eventSink
|
eventSink = eventSink
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2022-2024 New Vector Ltd.
|
* Copyright 2022-2024 New Vector Ltd.
|
||||||
|
|
@ -37,17 +38,8 @@ dependencies {
|
||||||
implementation(projects.services.apperror.api)
|
implementation(projects.services.apperror.api)
|
||||||
api(projects.features.invitepeople.api)
|
api(projects.features.invitepeople.api)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(libs.test.mockk)
|
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.usersearch.test)
|
testImplementation(projects.libraries.usersearch.test)
|
||||||
testImplementation(projects.services.apperror.test)
|
testImplementation(projects.services.apperror.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@ import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
||||||
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
|
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleState
|
import io.element.android.features.invitepeople.api.InvitePeopleState
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.map
|
import io.element.android.libraries.architecture.map
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
|
import io.element.android.libraries.architecture.runUpdatingState
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
@ -73,6 +75,8 @@ class DefaultInvitePeoplePresenter(
|
||||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
|
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||||
|
|
||||||
val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) {
|
val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) {
|
||||||
if (joinedRoom == null) {
|
if (joinedRoom == null) {
|
||||||
val result = matrixClient.getJoinedRoom(roomId)
|
val result = matrixClient.getJoinedRoom(roomId)
|
||||||
|
|
@ -116,7 +120,7 @@ class DefaultInvitePeoplePresenter(
|
||||||
}
|
}
|
||||||
is InvitePeopleEvents.SendInvites -> {
|
is InvitePeopleEvents.SendInvites -> {
|
||||||
room.dataOrNull()?.let {
|
room.dataOrNull()?.let {
|
||||||
sessionCoroutineScope.sendInvites(it, selectedUsers.value)
|
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is InvitePeopleEvents.CloseSearch -> {
|
is InvitePeopleEvents.CloseSearch -> {
|
||||||
|
|
@ -128,12 +132,13 @@ class DefaultInvitePeoplePresenter(
|
||||||
|
|
||||||
return DefaultInvitePeopleState(
|
return DefaultInvitePeopleState(
|
||||||
room = room.map { },
|
room = room.map { },
|
||||||
canInvite = selectedUsers.value.isNotEmpty(),
|
canInvite = selectedUsers.value.isNotEmpty() && !sendInvitesAction.value.isLoading(),
|
||||||
selectedUsers = selectedUsers.value,
|
selectedUsers = selectedUsers.value,
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isSearchActive = searchActive,
|
isSearchActive = searchActive,
|
||||||
searchResults = searchResults.value,
|
searchResults = searchResults.value,
|
||||||
showSearchLoader = showSearchLoader.value,
|
showSearchLoader = showSearchLoader.value,
|
||||||
|
sendInvitesAction = sendInvitesAction.value,
|
||||||
eventSink = ::handleEvents,
|
eventSink = ::handleEvents,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -141,16 +146,21 @@ class DefaultInvitePeoplePresenter(
|
||||||
private fun CoroutineScope.sendInvites(
|
private fun CoroutineScope.sendInvites(
|
||||||
room: JoinedRoom,
|
room: JoinedRoom,
|
||||||
selectedUsers: List<MatrixUser>,
|
selectedUsers: List<MatrixUser>,
|
||||||
|
sendInvitesAction: MutableState<AsyncAction<Unit>>,
|
||||||
) = launch {
|
) = launch {
|
||||||
val anyInviteFailed = selectedUsers
|
sendInvitesAction.runUpdatingState {
|
||||||
.map { room.inviteUserById(it.userId) }
|
val anyInviteFailed = selectedUsers
|
||||||
.any { it.isFailure }
|
.map { room.inviteUserById(it.userId) }
|
||||||
|
.any { it.isFailure }
|
||||||
|
|
||||||
if (anyInviteFailed) {
|
if (anyInviteFailed) {
|
||||||
appErrorStateService.showError(
|
appErrorStateService.showError(
|
||||||
titleRes = CommonStrings.common_unable_to_invite_title,
|
titleRes = CommonStrings.common_unable_to_invite_title,
|
||||||
bodyRes = CommonStrings.common_unable_to_invite_message,
|
bodyRes = CommonStrings.common_unable_to_invite_message,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.invitepeople.impl
|
||||||
|
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleState
|
import io.element.android.features.invitepeople.api.InvitePeopleState
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
@ -22,5 +23,6 @@ data class DefaultInvitePeopleState(
|
||||||
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
|
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
|
||||||
val selectedUsers: ImmutableList<MatrixUser>,
|
val selectedUsers: ImmutableList<MatrixUser>,
|
||||||
override val isSearchActive: Boolean,
|
override val isSearchActive: Boolean,
|
||||||
|
override val sendInvitesAction: AsyncAction<Unit>,
|
||||||
override val eventSink: (InvitePeopleEvents) -> Unit
|
override val eventSink: (InvitePeopleEvents) -> Unit
|
||||||
) : InvitePeopleState
|
) : InvitePeopleState
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.features.invitepeople.impl
|
package io.element.android.features.invitepeople.impl
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
@ -68,6 +69,11 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
|
||||||
showSearchLoader = true,
|
showSearchLoader = true,
|
||||||
),
|
),
|
||||||
aDefaultInvitePeopleState(room = AsyncData.Failure(Exception("Room not found"))),
|
aDefaultInvitePeopleState(room = AsyncData.Failure(Exception("Room not found"))),
|
||||||
|
aDefaultInvitePeopleState(
|
||||||
|
canInvite = false,
|
||||||
|
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||||
|
sendInvitesAction = AsyncAction.Loading,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +99,7 @@ private fun aDefaultInvitePeopleState(
|
||||||
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
|
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
|
||||||
isSearchActive: Boolean = false,
|
isSearchActive: Boolean = false,
|
||||||
showSearchLoader: Boolean = false,
|
showSearchLoader: Boolean = false,
|
||||||
|
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
): DefaultInvitePeopleState {
|
): DefaultInvitePeopleState {
|
||||||
return DefaultInvitePeopleState(
|
return DefaultInvitePeopleState(
|
||||||
room = room,
|
room = room,
|
||||||
|
|
@ -102,6 +109,7 @@ private fun aDefaultInvitePeopleState(
|
||||||
selectedUsers = selectedUsers,
|
selectedUsers = selectedUsers,
|
||||||
isSearchActive = isSearchActive,
|
isSearchActive = isSearchActive,
|
||||||
showSearchLoader = showSearchLoader,
|
showSearchLoader = showSearchLoader,
|
||||||
|
sendInvitesAction = sendInvitesAction,
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -409,10 +409,23 @@ internal class DefaultInvitePeoplePresenterTest {
|
||||||
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||||
// Send invites
|
// Send invites
|
||||||
initialState.eventSink(InvitePeopleEvents.SendInvites)
|
initialState.eventSink(InvitePeopleEvents.SendInvites)
|
||||||
|
|
||||||
|
// Can't invite in the loading state
|
||||||
|
awaitItem().run {
|
||||||
|
assertThat(sendInvitesAction.isLoading()).isTrue()
|
||||||
|
assertThat(canInvite).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
delay(1_000)
|
delay(1_000)
|
||||||
inviteUserResult.assertions().isCalledOnce().with(
|
inviteUserResult.assertions().isCalledOnce().with(
|
||||||
value(selectedUser.userId)
|
value(selectedUser.userId)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Can invite again once the action is finished
|
||||||
|
awaitItem().run {
|
||||||
|
assertThat(sendInvitesAction.isReady()).isTrue()
|
||||||
|
assertThat(canInvite).isTrue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,6 +458,13 @@ internal class DefaultInvitePeoplePresenterTest {
|
||||||
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||||
// Send invites
|
// Send invites
|
||||||
initialState.eventSink(InvitePeopleEvents.SendInvites)
|
initialState.eventSink(InvitePeopleEvents.SendInvites)
|
||||||
|
|
||||||
|
// Can't invite in the loading state
|
||||||
|
awaitItem().run {
|
||||||
|
assertThat(sendInvitesAction.isLoading()).isTrue()
|
||||||
|
assertThat(canInvite).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
delay(1_000)
|
delay(1_000)
|
||||||
inviteUserResult.assertions().isCalledOnce().with(
|
inviteUserResult.assertions().isCalledOnce().with(
|
||||||
value(selectedUser.userId)
|
value(selectedUser.userId)
|
||||||
|
|
@ -455,6 +475,12 @@ internal class DefaultInvitePeoplePresenterTest {
|
||||||
value(CommonStrings.common_unable_to_invite_title),
|
value(CommonStrings.common_unable_to_invite_title),
|
||||||
value(CommonStrings.common_unable_to_invite_message)
|
value(CommonStrings.common_unable_to_invite_message)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Can invite again once the action is finished
|
||||||
|
awaitItem().run {
|
||||||
|
assertThat(sendInvitesAction.isReady()).isTrue()
|
||||||
|
assertThat(canInvite).isTrue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import extension.setupDependencyInjection
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2024 New Vector Ltd.
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
|
@ -38,16 +39,9 @@ dependencies {
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
implementation(projects.appconfig)
|
implementation(projects.appconfig)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(libs.coroutines.test)
|
|
||||||
testImplementation(libs.molecule.runtime)
|
|
||||||
testImplementation(libs.test.robolectric)
|
|
||||||
testImplementation(libs.test.truth)
|
|
||||||
testImplementation(libs.test.turbine)
|
|
||||||
testImplementation(projects.features.invite.test)
|
testImplementation(projects.features.invite.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.tests.testutils)
|
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
|
||||||
testImplementation(projects.libraries.preferences.test)
|
testImplementation(projects.libraries.preferences.test)
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
testImplementation(projects.libraries.previewutils)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue